perf(agent pane): a streamed chunk re-renders only its row; each frame is parsed once

Merged
LA
3d ago
+471-61

Summary

HOT PATH AUDIT area F, rows W1 and W2 (AUDIT.md; lead decision 1). Every streamed chunk re-rendered the whole agent pane (AcpmuxPane, with about 20 memos over all rows), the Composer and Session Info. And each frame from acpmux was parsed twice.

  • Streaming row as its own store (streamingRow.ts): when a snapshot from the direct client differs from the one the pane drew only in the streaming reply's text, the pane keeps its snapshot. The row's newer text goes to a one-value store.
    • textOnlyRow is one pointer compare per row. summary, subagentViews and catalog, which the client rebuilds every time, compare by value.
    • The live VirtualTranscript reads the store (useSyncExternalStore) and draws that one row with its newer text and version.
    • Any other change draws through the pane as before and clears the store.
  • Safety: the fast path is taken only while the transcript draws the reply's row as the client holds it (drawsAsIs), so rows that conversation/turns.ts reshapes take the old path. rowsRef and snapshotRef still take every snapshot, so copy, menus and other imperative readers see the newest text.
  • One parse per frame: direct.ts parses each frame once and hands the parse to the wire log (wire.ts received(text, parsed)).
  • Test seam: acpmuxPerf.render(name) counts renders while measuring. loadRun reports them.

To the page owner (Leo): this touches App.tsx (pane listener, VirtualTranscript prop streams), direct.ts (receive) and wire.ts.

Testing

Red tests first (on this cloud VM, Chrome 156; render and parse counts are deterministic, times are directional only):

  • test/agent-pane-stream-renders.test.ts: the real pane on the in-page mock daemon, 700 seeded turns, one text-only turn at full speed.
    • Base 7035f8f337f: 111 chunks gave 112 pane renders and 112 composer renders (fails).
    • Head: 224 chunks gave 5 and 5; the reply's row rendered 221 times. loadRun React p95 went from 14.5 ms to 9.5 ms, work p95 from 55 ms to 21 ms.
  • test/agent-pane-session-change-coalesce.test.ts, "a streamed chunk's frame is parsed once": base 100 JSON.parse calls for 50 frames (fails); head 50.

Existing tests with the fix (VM Chrome; WebKit not installed here, so only Chromium ran): agent-pane-streaming-no-flicker, agent-pane-stream-jitter (10), streaming-parked-still (4, 1 skip), transcript-row-renders, agent-pane-session-change-coalesce (8), composer-stream-typing, streaming-markdown-forming (38), chat-find: all pass. bun run check passes.

Observed / Not run

  • Observed: the runs above on this Linux VM.
  • Not run: WebKit (the engine cmux NEXT draws the pane with); the GitHub Actions browser lane runs it. No fleet timing (browser tests never run on fleet Macs).

Changelog

  • Changed: agent chats stay smooth while a long reply streams; typing and scrolling no longer compete with each streamed word.

No capture: no visible change.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes the agent pane so each streamed chunk re-renders only the streaming reply's row instead of the whole pane (~20 memos, the Composer, and Session Info), and parses each acpmux frame once instead of twice.

  • The reply's newer text goes to a one-value store (streamingRow.ts) read only by the live transcript; any other change draws through the pane and clears the store, and chunks draw through the pane while an artifact or Find is open.
  • The live line under the reply stays quiet while it streams: its quiet key moves with the reply's newer version.
  • The fast path runs only while the transcript draws the reply's row unchanged, snapshots compare by identity without serializing, and rowsRef and snapshotRef still take every snapshot.
  • direct.ts parses each frame once and passes the parse to the wire log.