Split the newest reply once its turn ends, and make the UI harness reusable

The transcript's remaining lag was the newest assistant reply: transcriptUnits
kept the last row whole -- right while it streams (splitting a changing text
is a parse per delta), wrong forever after, so a session that ends on a long
reply drew it as one lazy-list item with every node alive. On a Pixel 9 Pro
XL that was 13.8ms of draw phase a frame, 79% of it the framework's own
per-node bookkeeping, against a 34,996px item.

An AssistantMsg now carries `settled`, folded from the status event that ends
its turn (status changes are transcript events with seqs, so replay settles
the same way), and cleared if a delta ever grows the message again. A settled
newest reply splits like every other. Folding it -- rather than reading the
screen's status -- routes the resplit through the held-events gate, so it can
only happen at the newest end while pinned, never under a reader. The
"session is working" predicate now lives once, in sessionWorking().

Measured on the emulator, same session and gestures, a 43KB reply as the
last row: draw phase 3.92ms -> 1.20ms per frame, framework share 3.07ms
(78%) -> 0.54ms (45%), worst single measure 82.5ms -> 9.1ms. The report's
"on screen" line went from one 60,674px AssistantMsg to five blocks of
95-846px. A live streamed turn settles and splits the moment it goes idle.

The harness half, asked for by Bryan: ui-sandbox.sh now derives its port and
root from the checkout name (two checkouts' sandboxes cannot reach each
other), keeps its token in ~/.config/ai-app/sandbox-token and salvages
enrolled device tokens across restarts (enrol the emulator once, ever), and
gained the driving verbs every UI session was re-inventing in /tmp: spawn,
send (text or @file), api. transcript-bench.sh is the standard
scroll-and-report measurement. AGENTS.md documents all of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5 committed 2026-09-01 17:19:19 -04:00
1 parent 917eb9a7b3
commit 7a48f8ff1f
8 files changed
+250 -23

No files matched your search

@@ -126,10 +126,12 @@ sealed class TranscriptUnit {
* Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
* message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones
* those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth
* splitting. The reply still arriving -- the last row -- stays whole: its text changes with every
* delta, and splitting it here would parse the whole message per delta on whichever thread is
* composing. [AssistantMessage]'s own streaming path already parses deltas off the main thread and
* gives the live message a layer per block.
* splitting. The reply still arriving -- the newest row, until the status event that ends its turn
* marks it [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta,
* and splitting it here would parse the whole message per delta on whichever thread is composing.
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
* live message a layer per block. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
* path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
@@ -163,7 +165,9 @@ fun transcriptUnits(
)
}
}
} else if (item is TranscriptItem.AssistantMsg && index != rows.lastIndex) {
} else if (
item is TranscriptItem.AssistantMsg && (item.settled || index != rows.lastIndex)
) {
var ordinal = 0
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
replies.partsOf(item.text).forEach { part ->