diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 8ff74a0..b0bd178 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -439,6 +439,31 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {} } +/** + * A page of a session's transcript, oldest first within the page. + * + * One request instead of one stream frame per event. The SSE stream is the right shape for live + * events and the wrong one for a backlog: opening an imported session replayed hundreds of frames + * before anything was readable, which looked exactly like the app loading top-down, because it was. + * + * [before] pages backwards for history somebody scrolls to; absent means the newest page. + */ +fun fetchTranscript( + settings: ServerSettings, + sessionId: String, + before: Long? = null, + limit: Int = 80, +): List { + val query = buildString { + append("?limit=").append(limit) + if (before != null) append("&before=").append(before) + } + return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> + val body = JSONArray(connection.inputStream.bufferedReader().readText()) + (0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) } + } +} + /** Switches a running session's model; the CLI changes it in place. */ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) { requestFromServer( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 97d2546..47e03c9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -47,7 +47,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers @@ -161,6 +163,14 @@ fun SessionScreen( // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } val activeStream = remember { AtomicReference(null) } + // The oldest sequence number loaded, and whether there is more behind + // it. Paging backwards is what keeps opening a long session cheap: the + // screen starts with the end of the conversation and fetches earlier + // pages only when somebody scrolls to them. + var oldestSeq by remember { mutableLongStateOf(0L) } + var moreHistory by remember { mutableStateOf(true) } + var loadingHistory by remember { mutableStateOf(false) } + var ready by remember { mutableStateOf(false) } val listState = rememberLazyListState() fun apply(entry: SeqEvent) { @@ -175,7 +185,25 @@ fun SessionScreen( // The stream lifecycle: connect, follow, and on any drop reconnect // from the cursor -- so a flaky link (or a backend restart) costs // nothing but the gap's latency. + // The newest page first, in one request, before the stream opens. The + // stream then starts from where that page ended, so it carries live + // events only -- which is what it is good at. LaunchedEffect(summary.id) { + try { + val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) } + page.forEach { apply(it) } + oldestSeq = page.firstOrNull()?.seq ?: 0L + moreHistory = oldestSeq > 1L + } catch (e: ApiException) { + // Not fatal: the stream below still replays from zero, which is + // slow but complete. Saying so beats silently showing nothing. + streamError = e.message + } + ready = true + } + + LaunchedEffect(summary.id, ready) { + if (!ready) return@LaunchedEffect while (true) { val stream = EventStream(settings, summary.id) activeStream.set(stream) @@ -217,6 +245,47 @@ fun SessionScreen( snapshotFlow { items.size } .collect { count -> if (followTail && count > 0) listState.scrollToItem(0) } } + // Reaching the far end of what is loaded -- the oldest item, which in + // this layout is the last index -- fetches the page before it. + LaunchedEffect(listState, items.size, moreHistory) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 } + .collect { last -> + if (!moreHistory || loadingHistory || items.isEmpty()) return@collect + if (last < items.size - 3) return@collect + loadingHistory = true + try { + val older = + withContext(Dispatchers.IO) { + fetchTranscript(settings, summary.id, before = oldestSeq) + } + if (older.isEmpty()) { + moreHistory = false + } else { + oldestSeq = older.first().seq + moreHistory = oldestSeq > 1L + // Folded oldest-first into a list of their own, then + // put in front: `foldEvent` merges streaming text + // into the item before it, so replaying an older page + // through the live list would glue it onto the newest + // message rather than its own. + var earlier = listOf() + older.forEach { entry -> + val event = entry.event + if ( + event !is SessionEvent.Status && event !is SessionEvent.UsageDelta + ) { + earlier = foldEvent(earlier, event) + } + } + items = earlier + items + } + } catch (_: ApiException) { + // Leave `moreHistory` alone: the next scroll asks again. + } finally { + loadingHistory = false + } + } + } LaunchedEffect(summary.setupName, summary.provider) { offeredModels = @@ -236,6 +305,8 @@ fun SessionScreen( } } + val running = status == "running" || status == "compacting" + fun act(action: () -> Unit) { scope.launch { try { @@ -411,36 +482,47 @@ fun SessionScreen( ) { Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}") } - // Beside the field they govern, and showing their current - // value rather than a label: what this session is set to is - // the thing worth reading at a glance, and the control for - // changing it is the same object. - if (offeredModels.isNotEmpty()) { + // The settings share what is left after the actions have + // taken what they need. A Row hands out intrinsic widths in + // order and clips whatever runs past the edge, so with + // these laid out first the arrival of Stop pushed Send off + // the screen entirely -- the app's central control, gone at + // exactly the moment the app is most in use. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + if (offeredModels.isNotEmpty()) { + PickerButton( + current = model ?: "default", + options = offeredModels, + onPick = { chosen -> + model = chosen + act { setSessionModel(settings, summary.id, chosen) } + }, + ) + } PickerButton( - current = model ?: "default", - options = offeredModels, + current = permissionMode, + options = PERMISSION_MODES, onPick = { chosen -> - model = chosen - act { setSessionModel(settings, summary.id, chosen) } + permissionMode = chosen + act { setSessionPermissionMode(settings, summary.id, chosen) } }, ) } - PickerButton( - current = permissionMode, - options = PERMISSION_MODES, - onPick = { chosen -> - permissionMode = chosen - act { setSessionPermissionMode(settings, summary.id, chosen) } - }, - ) - Spacer(Modifier.weight(1f)) - if (status == "running" || status == "compacting") { + if (running) { OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) { Text("Stop") } Spacer(Modifier.width(8.dp)) } - Button(onClick = { send() }) { Text("Send") } + // "Queue" while a turn is in flight, because that is what + // sending then does: the message is injected at the next + // tool boundary rather than starting a turn of its own. + // Naming it Send there would promise something immediate + // and describe something that waits. + Button(onClick = { send() }) { Text(if (running) "Queue" else "Send") } } } } @@ -571,9 +653,24 @@ private fun PickerButton(current: String, options: List, onPick: (String var open by remember { mutableStateOf(false) } Box { TextButton(onClick = { open = true }) { - Text(current, style = MaterialTheme.typography.bodySmall) + // One line, truncated rather than wrapped: this sits in a row + // whose height is the buttons beside it, and a second line + // would move them. + Text( + current, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + // Not focusable, so opening it does not take focus from the message + // field and dismiss the keyboard. Changing the model mid-sentence + // is an aside, not a departure from what you were typing. + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + properties = PopupProperties(focusable = false), + ) { options.forEach { option -> DropdownMenuItem( text = { Text(option) }, diff --git a/server/src/routes.rs b/server/src/routes.rs index 8b4e7b4..0feb55c 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -64,6 +64,7 @@ pub fn router(manager: Arc) -> Router { .route("/sessions", get(list_sessions).post(spawn_session)) .route("/sessions/{id}", delete(delete_session)) .route("/sessions/{id}/events", get(events)) + .route("/sessions/{id}/transcript", get(transcript)) .route("/sessions/{id}/message", post(message)) .route("/sessions/{id}/answer", post(answer)) .route("/sessions/{id}/interrupt", post(interrupt)) @@ -720,6 +721,41 @@ struct EventsQuery { /// cursor from the transcript, then live events as they happen. An SSE /// auto-reconnect sends the last event id it saw as `Last-Event-ID`, which /// takes precedence over `after` -- same cursor, native mechanism. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TranscriptQuery { + /// Page backwards from this sequence number; absent means the newest. + #[serde(default)] + before: Option, + #[serde(default = "default_window")] + limit: usize, +} + +fn default_window() -> usize { + 80 +} + +/// A page of a session's transcript, newest first to open with. +/// +/// One request rather than one stream frame per event. The SSE stream +/// stays as it is and remains the right shape for *live* events, which +/// arrive one at a time by nature; it is only the backlog that has to +/// stop pretending to be live. +async fn transcript( + State(manager): State>, + UrlPath(id): UrlPath, + Query(query): Query, +) -> Result>, ApiError> { + let session = lookup(&manager, &id)?; + let events = crate::session::transcript::read_window( + session.transcript_path(), + query.before, + query.limit, + ) + .map_err(bad_request)?; + Ok(axum::Json(events)) +} + async fn events( State(manager): State>, UrlPath(id): UrlPath, diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index b1c243a..1a8373f 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -73,6 +73,29 @@ impl Transcript { /// Replays every event with `seq > after`, oldest first. A missing file is /// an empty transcript, not an error -- the session just hasn't produced an /// event yet. +/// A window of the transcript ending just before `before`, newest-biased. +/// +/// The screen opens on the end of a conversation, not the start of it, and +/// the end is all it can show at once. Replaying the whole file to get +/// there costs one network frame per event -- on an 863-event import that +/// was several seconds of messages arriving oldest-first, which reads as +/// the app loading top-down because that is exactly what it was doing. +/// +/// `before` pages backwards for history somebody actually scrolls to; the +/// file is read whole each time because a transcript is small and a +/// seek-backwards reader would be a lot of machinery for a list that fits +/// in memory anyway. +pub fn read_window(path: &Path, before: Option, limit: usize) -> Result> { + let mut all = read_after(path, 0)?; + if let Some(before) = before { + all.retain(|entry| entry.seq < before); + } + if all.len() > limit { + all.drain(..all.len() - limit); + } + Ok(all) +} + pub fn read_after(path: &Path, after: u64) -> Result> { let file = match File::open(path) { Ok(file) => file,