Parse a reply's markdown once per composition, not once per delta

Scrolling was laggy, and measuring said where the time went. Instrumenting
the transcript's main-thread work on the emulator, against `/stream 200`:
markdown parsing ran fifty-eight times in three seconds -- once per streamed
delta, each one re-parsing the whole message the reply had grown into -- for
49-78ms of main-thread work per three seconds, with single parses reaching
7.3ms. That is most of a frame at 60Hz and more than a whole one at 120.
Everything else the transcript does per event was under a tenth of it.

So only the first parse stays on the composing thread. That one has to: the
renderer's asynchronous path draws an empty loading slot until its result
arrives, which measures a row at nothing before it is measured at its real
height, and the whole transcript above it collapses and springs back. Every
parse after the first is the same row growing, and there is a previous parse
to keep drawing until the new one lands -- so those go to a background
thread and no frame is ever without a height. What is on screen stays a real
prefix of the reply rather than a guess at it; it is simply one parse behind.

The same measurement found `loaded` costing an ArrayList copy per event, and
nothing reading it. It recorded every event the screen had ever seen against
the possibility that a page arriving in front of them would need the events
themselves to stitch on -- but `joinPages` heals the boundary from the folded
rows and has since it was written, so this was a list that only ever grew.

Checked on the emulator with ui-trace at 1kHz. Streaming at the newest end:
the row's bottom edge holds at y=1940 while it grows upward, and the header,
status row and composer do not move for six seconds. Scrolled back with a
reply streaming: nothing moves at all, 0 of 45 elements over five seconds.
Scrolling a mixed transcript: rows keep a constant height as they translate,
so none of them arrives blank and fills in afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 13:24:55 -04:00
1 parent 30ebf4e25c
commit 62c22e38b7
2 files changed
+36 -23

No files matched your search

@@ -595,9 +595,6 @@ 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>()) }
// Sent, but not yet read by the session -- which is when the backend
// records it and it comes back as a row. Until then it is drawn below
// the working indicator, because that is where it is in the session's
@@ -680,11 +677,6 @@ fun SessionScreen(
if (event is SessionEvent.CommandSent) {
waitingCommands = waitingCommands.filterNot { it.first == event.id }
}
// 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, entry)
}
@@ -824,7 +816,6 @@ fun SessionScreen(
// screen -- `apply` refills them, and scrolling
// up pages the rest back in as it always does.
items = listOf()
loaded = listOf()
held = listOf()
oldestSeq = 0L
moreHistory = true