diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 0ec3305..c05f7b9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -2,6 +2,7 @@ package com.example.aiapp import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.font.FontFamily @@ -10,6 +11,7 @@ import androidx.compose.ui.unit.TextUnit import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownTypography +import com.mikepenz.markdown.model.parseMarkdown /** * An assistant's reply, rendered as the markdown it is written in. @@ -24,8 +26,22 @@ import com.mikepenz.markdown.m3.markdownTypography @Composable fun MarkdownText(text: String, modifier: Modifier = Modifier) { val body = MaterialTheme.typography.bodyLarge + // Parsed here, in the composition, rather than by the overload that takes the text itself. + // That one parses in a coroutine and draws an empty loading slot until the result arrives -- + // so a row is measured at nothing before it is measured at its real height, and the + // transcript above it collapses and springs back. Seen with five replies on screen at once, + // every one of them blank, the whole conversation shrunk to fit a single screen; a moment + // later it was all there again. That is the "skipping up and down" this list must never do, + // and no amount of scroll anchoring can survive a row that lies about its height first. + // + // The cost is the parse on the main thread, which is the trade being made deliberately: a + // few milliseconds of work at the moment a row is composed, against a layout that is wrong + // every time one is. If a very long reply ever makes that visible, the answer is the + // renderer's streaming state -- which parses incrementally -- and not going back to a + // placeholder with no height. + val parsed = remember(text) { parseMarkdown(text) } Markdown( - content = text, + parsed, colors = markdownColor( text = MaterialTheme.colorScheme.onSurface, 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 78ed30d..802c07f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -483,12 +483,41 @@ fun SessionScreen( var loadingHistory by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) } val listState = rememberLazyListState() + // Whether the newest message is on screen right now. The list is laid out from the bottom + // (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of + // it. This is what the jump-to-newest button watches: it is about what the reader can see. + // + // It is also the gate on everything the list draws -- see [record]. + val atNewest by remember { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + } + // Transcript events that arrived while somebody was reading further back, in the order they + // arrived, waiting for them to return to the newest end. See [record] for why. + var held by remember { mutableStateOf(listOf()) } // What is actually drawn: the transcript with runs of adjacent tool // calls folded into one row each. val rows = remember(items) { groupToolRuns(items) } - fun apply(entry: SeqEvent) { - lastSeq.set(entry.seq) + /** + * Everything the transcript list draws, from one event. + * + * Separate from [apply] because it is the half that is allowed to wait. The list anchors on the + * leading edge of its first visible item, which in this upside-down layout is that item's + * *bottom* -- so a row that grows pushes everything already on screen upwards, and the view + * travels toward the newest end without anybody scrolling. Measured against a reply streamed in + * four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very + * bottom, forty lines further on than where it was left. + * + * Insertions were never the problem -- the list is keyed, so a row arriving at either end + * leaves the anchor where it is, and reading back through history while a session works has + * always been still. What cannot be allowed is a row that is already there changing height, and + * the one guarantee that covers every way that happens -- a reply streaming, a tool's output + * arriving, a queued bubble appearing above the anchor -- is to change nothing at all while + * somebody is reading further back. + */ + fun record(entry: SeqEvent) { // The oldest event this view holds, which is what paging backwards // starts from. Maintained here rather than by each loader: the // first page and a stream reset both begin an empty view, and one @@ -497,6 +526,44 @@ fun SessionScreen( oldestSeq = entry.seq moreHistory = entry.seq > 1L } + val event = entry.event + // The message coming back is the session saying it has + // read it, so the bubble held below the indicator becomes + // the row `foldEvent` is about to add. + // Waiting, then read. Matched by id: the same message sent twice is two + // bubbles, and clearing by text would take away whichever matched first. + if (event is SessionEvent.MessageQueued) { + queued = queued + (event.id to event.text) + } + if (event is SessionEvent.UserMessage) { + queued = queued.filterNot { it.first == event.id } + } + // Waiting, then gone: a command leaves this list when the session takes it, + // and the row it becomes is added by `foldEvent` in the same pass. + if (event is SessionEvent.CommandQueued) { + waitingCommands = waitingCommands + (event.id to event.text) + } + if (event is SessionEvent.CommandSent) { + waitingCommands = waitingCommands.filterNot { it.first == event.id } + } + // Kept as well as folded. Folding is one-way -- a tool's + // start and end become one row -- so a page arriving in + // front of what is already here cannot be stitched on + // without the events themselves. + loaded = loaded + event + items = foldEvent(items, entry) + } + + /** + * One event, at the moment it arrives. + * + * What it says about the *session* -- running or not, which model, how many tokens -- lands + * immediately, because none of that is drawn in the list and freezing it would trade a + * transcript that jumps for a status row that lies. What it adds to the transcript goes through + * [record], which waits for the reader to be at the newest end. + */ + fun apply(entry: SeqEvent) { + lastSeq.set(entry.seq) when (val event = entry.event) { // Taken, not accumulated: the server's running total is on the event, and adding // up the deltas this screen happened to receive counted one page of a conversation @@ -526,34 +593,9 @@ fun SessionScreen( } status = event.state } - // The message coming back is the session saying it has - // read it, so the bubble held below the indicator becomes - // the row `foldEvent` is about to add. Matched by text -- - // all that distinguishes one message from an identical - // earlier one -- and only the first match, so two - // identical messages wait twice. - // Waiting, then read. Matched by id: the same message sent twice is two - // bubbles, and clearing by text would take away whichever matched first. - if (event is SessionEvent.MessageQueued) { - queued = queued + (event.id to event.text) - } - if (event is SessionEvent.UserMessage) { - queued = queued.filterNot { it.first == event.id } - } - // Waiting, then gone: a command leaves this list when the session takes it, - // and the row it becomes is added by `foldEvent` in the same pass. - if (event is SessionEvent.CommandQueued) { - waitingCommands = waitingCommands + (event.id to event.text) - } - if (event is SessionEvent.CommandSent) { - waitingCommands = waitingCommands.filterNot { it.first == event.id } - } - // Kept as well as folded. Folding is one-way -- a tool's - // start and end become one row -- so a page arriving in - // front of what is already here cannot be stitched on - // without the events themselves. - loaded = loaded + event - items = foldEvent(items, entry) + // In order, always: one late event recorded ahead of the backlog would fold a + // streamed delta into whatever row happened to be last by then. + if (atNewest && held.isEmpty()) record(entry) else held = held + entry } } } @@ -626,6 +668,7 @@ fun SessionScreen( // up pages the rest back in as it always does. items = listOf() loaded = listOf() + held = listOf() oldestSeq = 0L moreHistory = true }, @@ -653,13 +696,17 @@ fun SessionScreen( // stays started. DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } - // Whether the newest message is on screen right now. The list is laid out from the bottom - // (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of - // it. This is what the jump-to-newest button watches: it is about what the reader can see. - val atNewest by remember { - derivedStateOf { - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - } + // Back at the newest end, so the backlog [apply] held can land. Everything at once rather + // than paced out: they are at the bottom, which is the one place the list is allowed to + // follow new content, and drip-feeding it would only make that following last longer. + LaunchedEffect(listState) { + snapshotFlow { atNewest && held.isNotEmpty() } + .collect { due -> + if (!due) return@collect + val backlog = held + held = listOf() + backlog.forEach { record(it) } + } } // Whether they *chose* to be there, which is a different question and the one that decides // whether an arriving message brings the view with it. diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 23fbd1e..1923fb1 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -25,6 +25,9 @@ //! This is exactly the event vocabulary the real drivers produce, so a UI //! that renders echo sessions correctly renders the real thing. //! +//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the +//! shape a real model's reply arrives in, and the one where the row a +//! reader is anchored to is the row that keeps changing height. //! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of //! different lengths, single tool calls, runs of adjacent ones, images //! and a peer message. Rows of every shape and height the app draws, in @@ -326,6 +329,9 @@ impl EchoDriver { // rather than trusted: this is a test affordance, and a session // pinned running for an hour by a typo is a worse outcome than a // short wait. + let stream = text + .strip_prefix("/stream") + .map(|rest| rest.trim().parse::().unwrap_or(400).clamp(1, 4000)); let mixed = text .strip_prefix("/mixed") .map(|rest| rest.trim().parse::().unwrap_or(12).clamp(1, 400)); @@ -410,6 +416,23 @@ impl EchoDriver { return; } + // One long answer arriving in small pieces, which is what a real + // model does and what `/slow` does not: `/slow` emits a line a + // second, so its message grows in steps a reader can watch one + // at a time. A jump caused by the *anchor row itself* changing + // height needs growth that is continuous. + if let Some(pieces) = stream { + for i in 0..pieces { + let len = 3 + (i * 7) % 14; + send(Event::AssistantText { + delta: format!("{i}{} ", "x".repeat(len)), + }); + tokio::time::sleep(Duration::from_millis(50)).await; + } + finish(); + return; + } + if let Some(beats) = mixed { for beat in 1..=beats { write_beat(&sink, &dir, beat).await;