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

@@ -68,11 +68,27 @@ private const val RECONNECT_DELAY_MS = 1500L
* from the last seq seen, so there is no separate history fetch to drift from it.
*/
sealed class TranscriptItem {
data class UserMsg(val text: String) : TranscriptItem()
/**
* The transcript sequence number this row started at, and its identity on screen.
*
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
* page of history is an insertion at the far end. Without an identity that survives both, the
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A seq is the right identity because it is what the transcript itself is ordered by, it never
* changes, and it is already carried by every event. A row built from several events -- a
* streaming message, a tool call and its result -- keeps the seq of the first, so it holds
* still while the rest of it arrives.
*/
abstract val seq: Long
data class AssistantMsg(val text: String) : TranscriptItem()
data class UserMsg(override val seq: Long, val text: String) : TranscriptItem()
data class AssistantMsg(override val seq: Long, val text: String) : TranscriptItem()
data class ToolRun(
override val seq: Long,
val id: String,
val tool: String,
val input: String,
@@ -97,19 +113,20 @@ sealed class TranscriptItem {
) : TranscriptItem()
data class QuestionCard(
override val seq: Long,
val id: String,
val prompt: String,
val options: List<String>,
val answer: String?,
) : TranscriptItem()
data class ErrorMsg(val message: String) : TranscriptItem()
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(val ref: String) : TranscriptItem()
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(val text: String) : TranscriptItem()
data class Note(override val seq: Long, val text: String) : TranscriptItem()
/**
* A compaction that happened, and what it recovered.
@@ -119,26 +136,37 @@ sealed class TranscriptItem {
* conversation, and for a minute or two in which the session was busy with nothing to show.
*/
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
val trigger: String?,
) : TranscriptItem()
}
fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<TranscriptItem> =
when (event) {
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(event.text)
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
when (val event = entry.event) {
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text)
is SessionEvent.AssistantText -> {
// Deltas accumulate into the message they're streaming.
// Deltas accumulate into the message they're streaming, which keeps the seq of the
// first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
items.dropLast(1) + last.copy(text = last.text + event.delta)
} else {
items + TranscriptItem.AssistantMsg(event.delta)
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
}
}
is SessionEvent.ToolStart ->
items + TranscriptItem.ToolRun(event.id, event.tool, event.input, "", done = false)
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
event.tool,
event.input,
"",
done = false,
)
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A
@@ -150,10 +178,25 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
items + TranscriptItem.ToolRun(event.id, "tool", "", event.output, done = true)
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
"tool",
"",
event.output,
done = true,
)
}
is SessionEvent.Question -> {
val card = TranscriptItem.QuestionCard(event.id, event.prompt, event.options, null)
val card =
TranscriptItem.QuestionCard(
entry.seq,
event.id,
event.prompt,
event.options,
null,
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
// own, which is what every question was before this.
@@ -180,7 +223,7 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
}
}
is SessionEvent.Status -> items
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of
// its own when there is not -- a person's own attachment belongs
@@ -192,11 +235,17 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
) {
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
} else {
items + TranscriptItem.ImageItem(event.ref)
items + TranscriptItem.ImageItem(entry.seq, event.ref)
}
is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(event.preTokens, event.postTokens, event.trigger)
is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]")
items +
TranscriptItem.CompactedNote(
entry.seq,
event.preTokens,
event.postTokens,
event.trigger,
)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> items
}
@@ -290,7 +339,7 @@ fun SessionScreen(
// front of what is already here cannot be stitched on
// without the events themselves.
loaded = loaded + event
items = foldEvent(items, event)
items = foldEvent(items, entry)
}
}
}
@@ -423,9 +472,8 @@ fun SessionScreen(
// message rather than its own.
var earlier = listOf<TranscriptItem>()
older.forEach { entry ->
val event = entry.event
if (event !is SessionEvent.UsageDelta) {
earlier = foldEvent(earlier, event)
if (entry.event !is SessionEvent.UsageDelta) {
earlier = foldEvent(earlier, entry)
}
}
items = earlier + items
@@ -586,7 +634,7 @@ fun SessionScreen(
// are in the session's reading of events: after everything
// it has taken in, and not yet taken in themselves.
if (queued.isNotEmpty()) {
item {
item(key = "queued") {
Column(horizontalAlignment = Alignment.End) {
queued.forEach { text -> UserBubble(text, pending = true) }
}
@@ -598,7 +646,7 @@ fun SessionScreen(
// is a place, and the eye is already there because that is
// where the newest message is.
if (running) {
item {
item(key = "indicator") {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
@@ -616,7 +664,7 @@ fun SessionScreen(
// Still said somewhere: a session whose process is gone
// cannot be typed at, and with the corner label removed
// nothing else on this screen would mention it.
item {
item(key = "exited") {
Text(
"exited",
style = MaterialTheme.typography.bodySmall,
@@ -629,7 +677,14 @@ fun SessionScreen(
// Grouped first: adjacent tool calls collapse into one row,
// which is a decision about this screen and not about the
// transcript the stream and paging share.
items(rows.asReversed()) { row ->
// Keyed, and this is what stops the list moving under whoever is reading
// it. Every new message is an insertion at index 0 here, so without a key the
// rows keep their positions and the content slides through them -- which looks
// exactly like the view scrolling by itself. The keys above matter for the same
// reason: the working indicator appearing and disappearing is another insertion
// at the same end. Paging older history is the opposite insertion and was
// already fine, and stays fine, because a key survives both.
items(rows.asReversed(), key = { it.seq }) { row ->
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
@@ -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
}
}