Skip to content

refactor(ui): stream the transcript incrementally instead of re-deriving it per token - #2034

Merged
Astro-Han merged 13 commits into
mainfrom
refactor/incremental-transcript-projection
Aug 3, 2026
Merged

refactor(ui): stream the transcript incrementally instead of re-deriving it per token#2034
Astro-Han merged 13 commits into
mainfrom
refactor/incremental-transcript-projection

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR B of #2030. Refs #2030 — PR A (the @astryxdesign/core tooltip 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:

  • Per delta. overlayShellRunUpdates re-folded from its original input on every render and never wrote its derived result back. A background command's durable revision permanently leads the tool_result snapshot persisted in messages, so merged.changed stayed true forever and the owning turn was rebuilt on every token.
  • Per phase boundary. Every refreshMessages replaced every turn wholesale, roughly 2 × steps + tools times per answer.

createTranscriptProjection owns 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. valuesEqual is the only judge of "did not change", and it is the only place identity is decided.

turn.tools and turn.timeline were two structures carrying the same tools, each rewritten separately. The timeline is now the single tool authority and tools is flattened out of it, which also makes the rewrite pass idempotent.

Fixing the projection alone delivers nothing. TurnView is a shallow-compare memo, so a stable turn only skips if every sibling prop is stable too — and deriveAppShellTurnViewModel rebuilt turnFooterActionsByTurn from 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. ChatView calls back a deriveTurnPresentation(turns) the shell supplies, and the shell keys its cache on those turn objects:

footerActionsByTurn[turn.turnId] = cache.get(turn) ?? derive(turn, …)

A turn the projection did not move costs one WeakMap hit 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.tsx subscribes to liveTurn and shellRunUpdates at 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 above ChatMessageSurface, and quote-companion-panel.tsx — which renders ChatView without any of these props — is untouched.

Consequences of the down-shift:

  • deriveAppShellTurnViewModel(messages, previous)createAppShellTurnPresentationDerivation().derive(turns, context), plus useAppShellTurnPresentation, which mirrors useTranscriptProjection: a useRef-held factory, no useMemo, no previous argument to forget to thread.
  • app-shell.tsx loses the useRef + useMemo + previous wiring entirely; it passes one prop.
  • ChatView's four Record props collapse into deriveTurnPresentation, and safeResumeAction loses its turnId — which turn may be resumed is the derivation's answer now, so the shell supplies only the state and the callback.
  • reconcileAppShellTurnViewModel, the Record-shaped reuseEqualEntries, and apps/desktop's dependency on valuesEqual no 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 ChatView already 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 rewrites tools[].result on any turn holding a background command, and the presentation does read turn.tools (sandbox-failure classification and the errored-tool count). No user-visible difference could be constructed — the overlay does not touch status, 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 main they are a no-op, and searching main for 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 (grep over packages/ and apps/ 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 reaches memo.
  • The per-tool shell-run overlay memo. Its comment claimed that without it "the owning turn is rebuilt on every streamed token" — that was false: with the memo in place applied.out !== tool still held, so the turn was rebuilt anyway, and what actually returned identity was the reconcileTurnIdentities at 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])) returned true, as did valuesEqual(new Date(0), new Date(999)) and valuesEqual(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, and TurnFooterContext.pendingActions is already a ReadonlySet. 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:

Input change Before After
text delta — heap 19.0 KiB 4.8 KiB
text delta — turn identities moved 5 / 107 1 / 107
refresh — heap 660 KiB 322 KiB
refresh — turn identities moved 107 / 107 0 / 107
full answer (658 deltas + 28 refreshes) — historical turn re-renders 5517 0

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). structuredClone of the message array is subtracted, since both shapes pay it:

10 turns 40 turns 120 turns / 1.0 MB
refresh — projection reconcile + cached derivation (this PR) 0.21 ms 0.20 ms 0.49 ms
refresh — same, plus the second materializeTurns + full derivation (before) 0.24 ms 0.27 ms 0.71 ms
— of which: the second authority alone, now removed 0.03 ms 0.08 ms 0.18 ms
— of which: reconcileTurnIdentities deep compare, added by this PR 0.05 ms 0.18 ms 0.44 ms
delta — projection only, per token 0.004 ms 0.012 ms 0.032 ms
delta — projection + derivation, per token 0.010 ms 0.026 ms 0.073 ms

Two costs a reviewer should see rather than infer:

  1. The refresh path pays a full deep comparison. reconcileTurnIdentities walks every turn's args and tool results; after IPC deserialization no string is reference-equal, so it degrades to memcmp over every Bash output in the session — 0.44 ms for a 1 MB transcript, growing linearly. refreshMessages fires 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.
  2. The derivation now runs per token, which it did not before (its useMemo was keyed on messages). At 120 turns that is 0.041 ms per token — rebuilding four Records 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:

  1. The projection lives in a useRef inside ChatView. It is a derivation accelerator, not authority: correctness comes from value reconciliation, so losing the state costs a recompute and never a wrong render. The sessionId reset 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 by valuesEqual, 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.
  2. turn.tools order 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.
  3. One render body writes a ref: chat-view.tsx's promptRailTurnsRef. (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 as previous and 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_unavailable pins a detached run as running. Once ownership flips, the update's status stays running and 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 uses Date.now() for startedAt (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.ts filters allMessages unmemoized, so the companion panel's ChatView re-runs materializeTurns over the user's own turns on every render (typically single digits, ≈0.037 ms). reconcileTurnIdentities already recovers TurnView's memo there, so this is residual overhead, not a re-render. A useMemo keyed on allMessages plus 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.
  • Two intern implementations remain: reconcileTurnIdentities (arrays of turns) and the presentation cache (per turn object). They answer different questions and share valuesEqual where 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.
  • Desktop E2E (npm --workspace @maka/desktop run e2e) — 63 passed.
  • packages/storage and packages/runtime fail in this environment for reasons this branch does not touch (/var vs /private/var symlink 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.

Mutation Killed by
reconcileTurnIdentities ignores a shortened list a turn missing from the durable snapshot is dropped…
Drop the sessionId reset a session switch shows the new session and retains nothing of the old
lastUpdates = updates (alias the caller's array) an update list mutated in place still advances the projection
Read messages once per projection call a plain text delta never touches the message log at all
valuesEqual skips tokens a refresh that only changes \tokens` moves the turn`
valuesEqual skips notes a refresh that only changes \notes` moves the turn`
valuesEqual skips timeline …\timeline`…`
valuesEqual skips status / statusSource …\status`…, …`statusSource`…`
valuesEqual no longer fails closed fails closed on anything that is not a plain object
Presentation derives without the per-turn cache 4 identity tests
Presentation cache key ignores lineage moves a turn whose lineage changed even though the turn itself did not
Presentation cache key ignores whether a badge target still exists drops a forward badge when its target turn leaves the transcript
Presentation cache key ignores pending actions moves only the turn whose action went pending
A locale change does not invalidate the cache relabels everything when the locale changes
useAppShellTurnPresentation rebuilds its derivation per render keeps one turn-presentation derivation across renders
Lineage badge arrays not reused keeps an untouched turn's lineage badges when the transcript grows
visibleMessages rebuilt on every render re-renders only the tail turn… and re-renders nothing when a refresh republishes…
ChatView stops reusing the prompt rail's turns array a delta must not rebuild the prompt rail observer
The presentation's failure copy, badges, or resume pairing never reach TurnView renders every per-turn field the real derivation produces (five separate cuts)
Shell-run children fold only when they follow their parent folds a child that renders ahead of the Bash owning the run
turn.tools reordered against turn.timeline turn.tools is exactly the tools its timeline renders, order included

Three mutations survived, each for a reason rather than a gap:

  • valuesEqual skips tools. Equivalent mutant: turn.tools is now exactly timelineTools(turn.timeline) (pinned by turn.tools is exactly the tools its timeline renders), so tools cannot move without timeline moving.
  • ChatView stops passing sessionId to 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.
  • Removing PromptAnchorRail's memo. 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-wide IntersectionObserver, one querySelector + observe per 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.

  • A literal NUL byte in the presentation cache key's join made git treat app-shell-turn-view-model.ts as binary, so the diff carrying this PR's core change showed up as Bin 4788 -> 9770 bytes and was unreviewable on GitHub. It is now 198 added / 74 removed lines of text.
  • The shell-run fold no longer depends on tool order. Rather than leave the order-sensitive consumer standing behind a turn.tools order this PR deliberately changed, parents are indexed by ref over 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.
  • The presentation's wires to TurnView are 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 real ChatView.
  • The prompt rail lost its second stabilization layer. Its NUL-joined id key and its turns ref guarded the same thing 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.
  • useAppShellTurnPresentation no longer pins its own identity. Nothing memoizes on it, so the useCallback bought nothing while the render-phase ref write it required would have frozen pending state the day ChatView is memoized.
  • Two docblocks still pointed at deriveAppShellTurnViewModel, which this PR deleted, and valuesEqual still justified its export by the interning rule this PR removed. It now documents the coupling that is real: the shell keys a WeakMap on the turn objects this comparison decides, so relaxing it fails twice — the turn keeps its identity and hits a stale presentation entry.

ChatView's deriveTurnPresentation prop 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 — and app-shell.stories.tsx contains exactly that shape.

Fake-DOM fixes this required

FakeElement.querySelector() returned null unconditionally, so the prompt rail's idByElement.size === 0 early return always fired and its entire observer path was unreachable — all three changes in perf(ui): keep the prompt rail out of the streaming path tested green against a DOM that could not express them. querySelector/querySelectorAll now resolve [attr="value"] selectors against the real tree, and the observer stubs count their own lifecycle — separately per kind. Sharing one tally let a ResizeObserver from anywhere in the tree satisfy observe > 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.ts asserted 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 a useMemo (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 from main when 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.

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
Astro-Han force-pushed the refactor/incremental-transcript-projection branch from 82e6c33 to 287c7d8 Compare August 3, 2026 17:41
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant