Hold the transcript still while somebody is reading further back
Two separate defects, both of which moved the list under the reader. The first: a row that grows drags the view toward the newest end. The list is laid out from the bottom, so it anchors on the first visible item's *bottom* edge -- and a reply streaming in extends that row upwards, pushing everything already on screen with it. Measured against a reply streamed in four hundred pieces: scrolling back one screen and waiting six seconds ended at the very bottom, forty lines further on than where it was left. So the transcript now only changes while the reader is at the newest end; anything arriving before then waits in order and lands when they return. Status, tokens and the model still update live, because none of those are drawn in the list and freezing them would trade a jumping transcript for a status row that lies. The second: every markdown row was measured at nothing before it was measured at its real height. The renderer's `content: String` overload parses in a coroutine and draws an empty loading slot until it finishes, so a row composes with no height and springs open a frame later. Seen with five replies on screen at once, all blank, the whole conversation shrunk to a single screen. Parsing in the composition costs a few milliseconds on the main thread and is worth it: no scroll anchoring can survive a row that lies about its height first. `/stream N` in the echo driver is what made the first one reproducible -- `/slow` emits a line a second, and the growth has to be continuous for the anchor row to drag. Verified on the emulator: scrolled back through a whole 400-piece stream, the transcript region is pixel-identical across ten seconds while the status row goes from working to idle; returning to the bottom brings the backlog in one go. Checked the tool-call rig too, which this change had no reason to touch -- paging back still works and every group still reads "Called 8 tools".
This commit is contained in:
1 parent
49d3439ec4
commit
de049bcec6
3 files changed
+124
-38
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<SeqEvent>()) }
|
||||
// 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.
|
||||
|
||||
@@ -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::<usize>().unwrap_or(400).clamp(1, 4000));
|
||||
let mixed = text
|
||||
.strip_prefix("/mixed")
|
||||
.map(|rest| rest.trim().parse::<usize>().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;
|
||||
|
||||
Reference in new issue
Block a user