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.
This commit is contained in:
iris committed 2026-08-28 23:25:13 -04:00
1 parent 2fe34176c0
commit e03b757dec
1 file changed
+21 -1
@@ -110,7 +110,17 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
items + TranscriptItem.ToolRun(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
// 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<SessionEvent>()) }
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)
}
}
}