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:
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(
|
||||
|
||||
Reference in new issue
Block a user