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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user