refactor(ui): stream the transcript incrementally instead of re-deriving it per token - #2034
Merged
Merged
Conversation
turn.tools and turn.timeline were two structures carrying the same tools, each rewritten separately by projectTurnTools. Derive tools by flattening the timeline instead, in materializeTurns, overlayLiveTurn and projectTurnTools alike, so a turn has one tool map and the rewrite pass is idempotent — projecting an unchanged tool set now returns the same turn object rather than an equal one. Also splits the shell-run overlay into its folded-updates and per-tool halves so a caller can apply one update to one tool without re-folding the whole list.
The transcript simulated incremental updates by re-deriving the whole view model per token and trusting memo's reference comparison to work out what had not changed. Two links in that chain were not idempotent, so the guess was wrong: overlayShellRunUpdates re-folded from its original input every delta and never wrote its result back, and a background command's durable revision permanently leads the tool_result snapshot persisted in messages, so the owning turn was rebuilt on every token; and every message refresh replaced every turn wholesale. createTranscriptProjection owns that derived state instead. It remembers the settled turns and hands the previous object back for any turn a refresh did not change, remembers the result of applying each shell-run update to each tool, and reports affectedTurnIds — derived from its own output, since the turn an event names is not the set of turns it affects (a ShellRun result folds into the Bash that owns its ref, which can live in an earlier turn). Coverage asserts identity, not just rendered output, and covers the invalidation boundaries in both directions: session switch, deletion, edit-and-resend, turns missing from the durable snapshot, live-to-settled handoff, lineage, ownership flips at a fixed revision, and live-only tools.
The rail's IntersectionObserver effect depended on the whole turns array, so every streamed token tore it down and rebuilt it — one querySelector and one observe per turn across the entire transcript. Key the effect on which turns exist, which is all the observer set depends on, memoize the rail, and hand it back the previous entries array when no prompt or answer text moved.
A stable turn only skips a memoized TurnView if every sibling prop is stable too. deriveAppShellTurnViewModel rebuilt turnFooterActionsByTurn and turnLineageBadgesByTurn from scratch on every refreshMessages, which fires at each step and tool boundary, so the memo failed on the footer array and the whole transcript re-rendered regardless of how stable its turns were — the transcript projection's refresh half bought nothing at the renderer. Intern both by value against the previous derivation, the same rule the projection uses for turns. Value equality is the only option available: messages arrive freshly deserialized over IPC, so no input carries identity across a refresh and identity has to be re-established from the output.
Every projection test called project() directly, so the wiring between the projection and the renderer was untested: a pass added downstream of it — a spread copy of each turn — restores the original defect with the whole suite green. That is the shape of #778 breaking #472, which this work exists to prevent. Extends the existing render-memo-boundary seam to mount the real ChatView and count renders per turn through a prop the test owns. The spread-copy mutation now fails this test.
…oint Three follow-ups from review: - The overlay rebuilt every turn's timeline to discover that only the turns holding a background command had moved. Scope the rebuild to those turns. - affectedTurnIds built a Map and a Set over the whole transcript on every streamed token for an answer no render path reads. Compute it on read. - overlayShellRunUpdates had no caller but its own test, so nothing would have gone red when it and the projection diverged. Remove it and drive its ShellRun behaviours through the projection that actually ships. Also stores its own copy of the update list, so the element-wise comparison is against a snapshot rather than against the caller's array aliased to itself, and covers two gaps the existing tests left: an ownership flip at an unchanged revision (the previous fixture's leading revision masked the ownership comparison entirely) and a lock that a text delta never re-reads the message log.
The shell derived footer actions, failed-turn labels and lineage badges by materializing the transcript a second time from the raw message log, so the turns it keyed them by were not the turns the renderer drew. Those props then had to be interned by value to line up with a memoized TurnView again. ChatView now hands its projected turns back to the shell through a `deriveTurnPresentation` callback, and the shell keys its cache on those turn objects: a turn the projection did not move costs one WeakMap hit and hands the same props back. The second authority and the interning both go away. Also drops `affectedTurnIds` (test-only, and repeating an input reported the previous step's set), the shell-run per-tool memo (its stated benefit was already provided by reconcileTurnIdentities, and it could serve a result derived from a stale tool), and makes `valuesEqual` fail closed outside plain objects and arrays. Refs #2030
Astro-Han
force-pushed
the
refactor/incremental-transcript-projection
branch
from
August 3, 2026 17:41
82e6c33 to
287c7d8
Compare
A turn's tools are a flattening of its timeline, and a live overlay moves that turn's tools to the end of it. That can order a background command's child tool ahead of the Bash that owns the run, and the fold scanned only the tools it had already folded — so it stopped folding there, leaving an orphan tool row and a parent that never took the child's revision. Look the parent up by ref over the whole list instead, and merge the children once their parent's position is known. The invariant test now carries two tools per turn on both the settled and the live path, so a reversal between `tools` and `timeline` is observable at all.
Moving the per-turn presentation onto the projected turns superseded the interning it replaced, but left three traces of it. A literal NUL byte in the cache key's `join` made git treat the file as binary, so the diff that carries this PR's core was unreviewable. Two docblocks still pointed at `deriveAppShellTurnViewModel`, which no longer exists, and `valuesEqual` still justified its export by an interning rule this change removed — it now documents the coupling that is real: the shell keys a WeakMap on the turn objects this comparison decides. `useAppShellTurnPresentation` claimed its own identity had to stay constant. Nothing memoizes on it, so the `useCallback` bought nothing while the render-phase ref write it required would have silently frozen pending state the day ChatView is memoized. Dropping both leaves the cache where it belongs, in the ref that holds the derivation.
The rail derived a NUL-joined id key and mirrored its turns into a ref so its observer effect would not re-run per streamed token. ChatView already hands the rail the same array while no rail-visible field moves, so both layers guarded the same thing — and each covered for the other well enough that removing either one on its own kept every test green. Keying the effect on the turns array leaves one guard instead of two, and turns the caller's reuse into something the observer count can see.
The stand-in this suite drives ChatView with returns empty maps for everything but the footer actions, so failure copy, lineage badges, and the resume pairing were derived in one test and rendered in another, never in the same frame. Cutting any of those five wires kept the suite green. A failed, regenerated transcript now runs the real derivation through the real ChatView and asserts each value reaches the DOM — with two failed turns, so offering the resume on every one of them is visible rather than hidden behind the single turn that could render it. The observer guard was also counting ResizeObserver and IntersectionObserver into one tally, so `observe > 0` passed even when the rail observed nothing. Each kind now counts separately and the guard pins the exact lifecycle.
The prop asked only for purity and idempotence, and a deriver rebuilt in the render body satisfies both while discarding the cache that makes the projection worth doing — with nothing turning red. Say so on the prop, and on the one-shot helper that is exactly the shape someone would copy out of a story.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR B of #2030. Refs #2030 — PR A (the
@astryxdesign/coretooltip patch) is independent, touches no file here, and is not substitutable for this: during streaming the tail turn's footer is a placeholder with no buttons, so the two remove different work.The transcript simulated incremental updates by re-deriving the whole view model per token and trusting
memo's reference comparison to work out which turns had not changed. Two links in that chain were not idempotent, so the guess was wrong:overlayShellRunUpdatesre-folded from its original input on every render and never wrote its derived result back. A background command's durable revision permanently leads thetool_resultsnapshot persisted in messages, somerged.changedstayed true forever and the owning turn was rebuilt on every token.refreshMessagesreplaced every turn wholesale, roughly2 × steps + toolstimes per answer.createTranscriptProjectionowns that derived state instead of re-deriving it: it remembers the settled turns and hands the previous object back for any turn a message refresh did not actually change.valuesEqualis the only judge of "did not change", and it is the only place identity is decided.turn.toolsandturn.timelinewere two structures carrying the same tools, each rewritten separately. The timeline is now the single tool authority andtoolsis flattened out of it, which also makes the rewrite pass idempotent.Fixing the projection alone delivers nothing.
TurnViewis a shallow-comparememo, so a stableturnonly skips if every sibling prop is stable too — andderiveAppShellTurnViewModelrebuiltturnFooterActionsByTurnfrom scratch on every refresh, because it derived those props by materializing the transcript a second time from the raw message log. Two independent authorities, neither able to give the other a stable key.The derivation moved down instead of being patched
An earlier revision of this PR interned those per-turn props by value after the fact. That works, but interning is manufacturing a key that should already exist. The root cause is the second
materializeTurns.So the derivation now reads the turns the projection already produced.
ChatViewcalls back aderiveTurnPresentation(turns)the shell supplies, and the shell keys its cache on those turn objects:A turn the projection did not move costs one
WeakMaphit and its footer, labels and badges come back as the same objects. The second authority and the interning disappear together, and prop stability falls out of turn stability rather than being re-established beside it.Direction matters here: the derivation moved down to where the turns are, not the projection up to where the derivation was.
chat-message-surface.tsxsubscribes toliveTurnandshellRunUpdatesat that layer precisely so a delta never reaches AppShell (#1985); hoisting the projection would have hoisted the delta with it and re-rendered the sidebar and composer on every token. Nothing moved aboveChatMessageSurface, andquote-companion-panel.tsx— which rendersChatViewwithout any of these props — is untouched.Consequences of the down-shift:
deriveAppShellTurnViewModel(messages, previous)→createAppShellTurnPresentationDerivation().derive(turns, context), plususeAppShellTurnPresentation, which mirrorsuseTranscriptProjection: auseRef-held factory, nouseMemo, nopreviousargument to forget to thread.app-shell.tsxloses theuseRef+useMemo+previouswiring entirely; it passes one prop.ChatView's fourRecordprops collapse intoderiveTurnPresentation, andsafeResumeActionloses itsturnId— which turn may be resumed is the derivation's answer now, so the shell supplies only the state and the callback.reconcileAppShellTurnViewModel, theRecord-shapedreuseEqualEntries, andapps/desktop's dependency onvaluesEqualno longer exist.One behavior change: the presentation is now derived from the projected turns, which are live-overlaid and have draining assistant messages filtered out. For the live overlay only the tail turn can differ, and
ChatViewalready replaces its footer with the non-actionable streaming placeholder while an answer is in flight (re-renders only the tail turn while an answer streams). The shell-run overlay is the exception worth naming: it rewritestools[].resulton any turn holding a background command, and the presentation does readturn.tools(sandbox-failure classification and the errored-tool count). No user-visible difference could be constructed — the overlay does not touchstatus, and its results carry no sandbox denial — so this is a narrowed claim, not a known defect.Also removed
All three were introduced earlier in this PR and removed later in it: relative to
mainthey are a no-op, and searchingmainfor their names finds nothing. They are listed because reviewing this branch commit by commit walks through them, not because the branch deletes anything that shipped. (overlayShellRunUpdates, described above, is the one real deletion.)affectedTurnIds. No production caller (grepoverpackages/andapps/outside tests found none), and it carried a trap: repeating an input returned the previous step's affected set, so a consumer using it to scope re-renders would have been wrong. The 13 tests that asserted it now assert turn object identity directly, which is the property that actually reachesmemo.applied.out !== toolstill held, so the turn was rebuilt anyway, and what actually returned identity was thereconcileTurnIdentitiesat the end. The memo also had a real hole: keying only on the entry (not the tool) would hand back a result derived from a stale tool, so a background command's output could freeze at an old value. Deleting it closes that without needing a test for it.valuesEqual's open key walk.valuesEqual(new Set([1]), new Set([2]))returnedtrue, as didvaluesEqual(new Date(0), new Date(999))andvaluesEqual(new Set([1]), {}). Every value it sees today is JSON-shaped, so there is no live defect — but it is exported as the rule every per-turn prop must intern by, andTurnFooterContext.pendingActionsis already aReadonlySet. It now fails closed outside plain objects and arrays: reporting "changed" costs a re-render, reporting "unchanged" freezes the UI.Measured
CDP, real Electron, the original 107-turn session:
Node, synthetic transcripts (5 messages per turn, an 8 KiB tool result per turn; a throwaway Node script, not committed, so these numbers are not reproducible from the branch; mean of 20 runs after 2 warmups, Apple silicon).
structuredCloneof the message array is subtracted, since both shapes pay it:materializeTurns+ full derivation (before)reconcileTurnIdentitiesdeep compare, added by this PRTwo costs a reviewer should see rather than infer:
reconcileTurnIdentitieswalks every turn'sargsand tool results; after IPC deserialization no string is reference-equal, so it degrades tomemcmpover every Bash output in the session — 0.44 ms for a 1 MB transcript, growing linearly.refreshMessagesfires at step and tool boundaries, not per token, and it buys eliminating a whole-transcript re-render, so this is a good trade; it is still a real new cost and it is now the dominant remaining one.useMemowas keyed onmessages). At 120 turns that is 0.041 ms per token — rebuilding fourRecords over turns whose entries are all cache hits. Below any frame budget, but it is new work on the token path.Review focus
This refactor inverts the failure mode. The old defect computed too much: slow, visible, measurable. The new risk is computing too little: stale UI, silent. So invalidation is tested with the same weight as stability, and every contract below was mutation-checked rather than observed green (table under Verification).
Two notes:
useRefinsideChatView. It is a derivation accelerator, not authority: correctness comes from value reconciliation, so losing the state costs a recompute and never a wrong render. ThesessionIdreset is hygiene — it bounds what is retained. Dropping the reset is caught (a session switch shows the new session and retains nothing of the old, because two sessions in one revision lineage carry the same turn ids with different content), but that is the reset's retention property; value correctness is guaranteed independently byvaluesEqual, which cannot reuse a turn whose content differs. A 4000-step property test comparing the stateful projection against a from-scratch projection at every step found no reachable divergence from removing it.turn.toolsorder changed. A tool that is both persisted and still live now sits where the timeline renders it rather than at its original index. Every consumer is order-insensitive (counts and filters); the one that was not,findShellRunParentIndex, was fixed in review rather than left to depend on an order this PR no longer guarantees — see below.chat-view.tsx'spromptRailTurnsRef. (app-shell.tsx's no longer does — the down-shift removed it;prompt-anchor-rail.tsx's was removed in review, see below.) This is the one place render is not a pure function. It is safe, and here is why: under StrictMode's double invocation the second call receives the first call's result aspreviousand returns it unchanged by value; under concurrent rendering a discarded render can advance the ref, but because the reuse is by value, the value stays correct and the only consequence is one extra re-render, never wrong UI; and the ref is per-component-instance, so multiple mounts cannot cross-talk. The cost is real and it is a deliberate trade for keeping the derivation and the render in the same object graph.Known, not fixed here
source_unavailablepins a detached run as running. Once ownership flips, the update'sstatusstaysrunningand the snapshot never advances. The projection carries that value faithfully and the flip itself invalidates correctly, but closing it belongs in the update state policy and needs a UX decision. Worth its own issue.overlayLiveTurn's fallback turn usesDate.now()forstartedAt(materialize.ts:328) — the one place the projection's output is not a pure function of its input, and the sole source of the 16/4000 mismatches the property test found. It affects only the path where streaming starts before the optimistic user turn materializes, and that turn re-renders per delta regardless.use-quote-companion.tsfiltersallMessagesunmemoized, so the companion panel's ChatView re-runsmaterializeTurnsover the user's own turns on every render (typically single digits, ≈0.037 ms).reconcileTurnIdentitiesalready recovers TurnView's memo there, so this is residual overhead, not a re-render. AuseMemokeyed onallMessagesplus a counter bumped at the ref's single mutation site would close it with no lag window (that hook currently discards the state value —const [, setOwnTurnTick] = useState(0)— so the counter has to be introduced, not just referenced), but it is off this PR's topic.reconcileTurnIdentities(arrays of turns) and the presentation cache (per turn object). They answer different questions and sharevaluesEqualwhere it matters.Verification
npm run typecheck(all workspaces),npm run lint,npm run format:check,npx knip— clean; knip output is byte-identical to the pre-change baseline.npm --workspace @maka/ui run test:dist— 316 pass.npm --workspace @maka/desktop run test:dist— 1585 pass.npm --workspace @maka/desktop run e2e) — 63 passed.packages/storageandpackages/runtimefail in this environment for reasons this branch does not touch (/varvs/private/varsymlink resolution in the path-containment tests; a writer-lock timing test). Neither package is modified by this branch.Mutation results
Every contract this PR adds was verified by injecting the mutation and confirming a test goes red.
reconcileTurnIdentitiesignores a shortened lista turn missing from the durable snapshot is dropped…sessionIdreseta session switch shows the new session and retains nothing of the oldlastUpdates = updates(alias the caller's array)an update list mutated in place still advances the projectionmessagesonce per projection calla plain text delta never touches the message log at allvaluesEqualskipstokensa refresh that only changes \tokens` moves the turn`valuesEqualskipsnotesa refresh that only changes \notes` moves the turn`valuesEqualskipstimeline…\timeline`…`valuesEqualskipsstatus/statusSource…\status`…,…`statusSource`…`valuesEqualno longer fails closedfails closed on anything that is not a plain objectmoves a turn whose lineage changed even though the turn itself did notdrops a forward badge when its target turn leaves the transcriptmoves only the turn whose action went pendingrelabels everything when the locale changesuseAppShellTurnPresentationrebuilds its derivation per renderkeeps one turn-presentation derivation across renderskeeps an untouched turn's lineage badges when the transcript growsvisibleMessagesrebuilt on every renderre-renders only the tail turn…andre-renders nothing when a refresh republishes…ChatViewstops reusing the prompt rail's turns arraya delta must not rebuild the prompt rail observerTurnViewrenders every per-turn field the real derivation produces(five separate cuts)folds a child that renders ahead of the Bash owning the runturn.toolsreordered againstturn.timelineturn.tools is exactly the tools its timeline renders, order includedThree mutations survived, each for a reason rather than a gap:
valuesEqualskipstools. Equivalent mutant:turn.toolsis now exactlytimelineTools(turn.timeline)(pinned byturn.tools is exactly the tools its timeline renders), sotoolscannot move withouttimelinemoving.ChatViewstops passingsessionIdto the projection. Nothing catches it, and nothing should: it is documented as hygiene, and its correctness-bearing counterpart (removing the reset itself) is caught. The previous revision of this description claimed this mutation was untestable based on the desktop ChatView suite; that suite is simply insensitive to it, and the claim was mis-sourced.PromptAnchorRail'smemo. Costs re-rendering a three-button list with identical props; pinning it would mean instrumenting the component's render count, which is more machinery than the regression is worth. The expensive half — tearing down and rebuilding a transcript-wideIntersectionObserver, onequerySelector+observeper turn per token — is now caught on its own (see below).Fixed in review
A round of independent review found six things worth acting on. All are in the last five commits.
joinmade git treatapp-shell-turn-view-model.tsas binary, so the diff carrying this PR's core change showed up asBin 4788 -> 9770 bytesand was unreviewable on GitHub. It is now 198 added / 74 removed lines of text.turn.toolsorder this PR deliberately changed, parents are indexed byrefover the whole list. The invariant test now carries two tools per turn on both paths, so a reversal is observable at all, and a new test pins the child-before-parent case directly.TurnVieware covered. The stand-in this suite renders with returns empty maps for everything but the footer actions, so failure copy, lineage badges and the resume pairing were derived in one test and rendered in another. Cutting any of five wires kept the suite green; a failed, regenerated transcript now runs the real derivation through the realChatView.ChatView's array reuse already guards — and covered for each other well enough that removing either one alone kept every test green. One guard remains, and the caller's reuse is now something the observer count can see.useAppShellTurnPresentationno longer pins its own identity. Nothing memoizes on it, so theuseCallbackbought nothing while the render-phase ref write it required would have frozen pending state the dayChatViewis memoized.deriveAppShellTurnViewModel, which this PR deleted, andvaluesEqualstill justified its export by the interning rule this PR removed. It now documents the coupling that is real: the shell keys aWeakMapon the turn objects this comparison decides, so relaxing it fails twice — the turn keeps its identity and hits a stale presentation entry.ChatView'sderiveTurnPresentationprop also now says what purity and idempotence do not imply: the deriver must carry a cache that outlives one render. A deriver rebuilt in the render body satisfies the documented contract, gives back every re-render this PR exists to avoid, and turns no test red — andapp-shell.stories.tsxcontains exactly that shape.Fake-DOM fixes this required
FakeElement.querySelector()returnednullunconditionally, so the prompt rail'sidByElement.size === 0early return always fired and its entire observer path was unreachable — all three changes inperf(ui): keep the prompt rail out of the streaming pathtested green against a DOM that could not express them.querySelector/querySelectorAllnow resolve[attr="value"]selectors against the real tree, and the observer stubs count their own lifecycle — separately per kind. Sharing one tally let aResizeObserverfrom anywhere in the tree satisfyobserve > 0, so that guard passed even with the rail's observer path unreachable; it now pins the exact{construct, observe, disconnect}.Claims in earlier revisions of this description that were wrong and are corrected above: the docblock in
ui-render-memo-boundary-contract.test.tsasserted that seam catches "a missing sessionId" (it does not — 11/11 stayed green); the quote-companion note gave "a one-render lag window" as the reason to defer auseMemo(there is no lag window; the real reason is scope) and named a binding that does not exist; the "Also removed" list read as deletions frommainwhen they are self-corrections inside this PR; a mutation row credited the prompt rail's effect key with a kill that the array reuse was actually making; and the reported test counts were pre-rebase.