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 5db0dda..140b8ff 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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, 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, event: SessionEvent): List = - when (event) { - is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(event.text) +fun foldEvent(items: List, entry: SeqEvent): List = + 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, event: SessionEvent): List { - 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, event: SessionEvent): List 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, event: SessionEvent): List - 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() 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( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index b8bdb51..68d820b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -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) : 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 } }