From e03b757dec09af7daa0125749dc1e8b38c045241 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 23:25:13 -0400 Subject: [PATCH] Stop losing tool calls at the seam between transcript pages A tool's start and end are two events folded into one row, and the fold only ever *updated* an existing row -- so an end whose start was not in the same fold changed nothing and vanished. Not a broken row: no row at all, which is indistinguishable from a tool that never ran, and is what Iris saw as gaps where she remembered work happening. Paging made it routine. Each page was folded on its own and prepended, so every seam split whatever spanned it: 30 tool ends in the first page of this conversation, one of them already orphaned before a single scroll. Two changes, because the two halves fail differently. Pages are re-folded from the events they came from rather than folded apart and stitched together. That needs the events kept beside the rows, since folding is one-way. One pass over everything loaded, paid only when somebody scrolls back, which is the moment they asked for it. And an end with no start now creates a row instead of disappearing. Its name is unknown from an end alone, so it says "tool" until the earlier page arrives and replaces it -- a row that admits what it does not know beats silence, because silence is a claim that nothing happened. Verified across a real seam: scrolling back through the 80-event boundary of an 864-event import is continuous, with no gaps where tool calls were. --- .../kotlin/com/example/aiapp/SessionScreen.kt | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 828be93..d656440 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -110,7 +110,17 @@ fun foldEvent(items: List, event: SessionEvent): List updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolEnd -> - updateTool(items, event.id) { it.copy(output = event.output, done = true) } + // Created when its start is not here, rather than dropped. A + // fold that only ever *updates* loses the whole call when the + // start fell outside the loaded window, and a tool call that + // renders as nothing is indistinguishable from one that never + // happened. The name is unknown from an end alone; loading the + // page before this one replaces the row with the real thing. + 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) + } is SessionEvent.Question -> items + TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null) @@ -169,6 +179,9 @@ fun SessionScreen( // screen starts with the end of the conversation and fetches earlier // pages only when somebody scrolls to them. var oldestSeq by remember { mutableLongStateOf(0L) } + // Every transcript event loaded, in order, beside the rows they folded + // into. See `apply`. + var loaded by remember { mutableStateOf(listOf()) } var moreHistory by remember { mutableStateOf(true) } var loadingHistory by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) } @@ -179,7 +192,14 @@ fun SessionScreen( when (val event = entry.event) { is SessionEvent.Status -> status = event.state is SessionEvent.UsageDelta -> totalTokens += event.tokens - else -> items = foldEvent(items, event) + else -> { + // 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, event) + } } }