Hold the reader's place when a message arrives

Scrolled back through a conversation, every new message dragged the view
with it -- which reads as the screen scrolling down on its own, at the
exact moment somebody is trying to read something else.

The list had no keys, so its rows were identified by position. The
transcript is drawn newest-first, so a new message is an insertion at
index 0: every existing row shifts up one index, the viewport stays on
the index it was on, and the content slides through it. The working
indicator appearing and disappearing did the same thing at the same end.

So rows now carry the identity they always had in the data. Every
transcript event has a seq, which is what the transcript is ordered by
and never changes, and every row keeps the seq of the first event behind
it -- a streaming message keeps the seq of its first delta, so it holds
still for the whole answer rather than becoming a new row on every
frame, and a tool call keeps its start's.

Paging older history is the same insertion from the other end, and it is
the thing this could plausibly have broken. Checked on the emulator:
scrolled back mid-turn, the view sat still through twenty seconds of
streamed deltas, and scrolling to the far end still fetched earlier
pages and stayed where it was while they arrived.
This commit is contained in:
iris committed 2026-08-29 14:47:31 -04:00
1 parent 5396da76c7
commit 279c76e8a1
2 files changed
+95 -26

No files matched your search

@@ -32,13 +32,27 @@ import androidx.compose.ui.unit.dp
* of "these belong together" must not reach back into it.
*/
sealed class TranscriptRow {
data class Single(val item: TranscriptItem) : TranscriptRow()
/**
* This row's identity in the list, taken from the first transcript event behind it.
*
* See [TranscriptItem.seq]: the list is keyed by this so that inserting a new message at one
* end, or a page of history at the other, moves the rows and not the reader.
*/
abstract val seq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() {
override val seq: Long
get() = item.seq
}
/** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
/** Stable across reloads because it is the first call's own id. */
val id: String
get() = calls.first().id
override val seq: Long
get() = calls.first().seq
}
}