Agent chat: long chats stream and scroll without whole-chat work per frame

Merged
TE
25d ago
+1339-165

Summary

In a 5,000-turn chat (25,000 rows, with 150-line edit diffs and 300-line test logs), the agent pane fell to about 12 fps while a reply streamed and the reader scrolled. Most of each frame went to passes over the whole chat: the client re-sorted every row on each text delta, and the turn view, layout, placement, minimap, session edits and several App memos each walked all 25,000 rows per snapshot. Scrolling also built and measured each newly visible row (Markdown, code and diff cards) inside the scroll's own frame.

Now:

  • Only changed rows are redone. Each of those passes keeps its last result for the rows before the first row that changed (compared by object), and redoes only the rest. The client keeps its sorted order up to date in place. turnViewFrom rebuilds from the turn holding the first change. Layout and drawn-height placement copy the unchanged prefix.
  • Rows around the reader mount ahead of time. The rows one viewport above and below where the reader is also mount, in a deferred render between frames (useDeferredValue). A scroll then finds them already drawn and measured.

A debug-only load harness drives this through the real client and reducer with the mock daemon (--showcase builds):

  • debug.agent_pane load_seed records a long, heavy session.
  • load_run streams a reply at full speed while scrolling up and down, and reports each frame's work (rAF start → after render), the long frames and phase timers.

Latent bug left alone: endedEditTurn (from #17174) compares the raw row.kind with "editedFiles", a kind only rowKind() produces. So it never requests a checkpoint, as on base. It is unchanged here except that it no longer reads every row.

Testing

Fleet capture Mac (M4 Pro, 60 Hz display), 5,000 turns, scroll at 3,000 pt/s. The 8.33 ms budget is what one frame gets at 120 Hz; the display can't show 120 Hz, so each frame's work is counted against it. Same driver for both builds:

RunBefore: interval p50 / p95, droppedBefore: work p50 / p95, over 8.33 msAfter: interval p50 / p95, droppedAfter: work p50 / p95 / p99, over 8.33 ms
Stream + scroll84 / 178 ms, 406 dropped78 / 154 ms, 61 of 10017 / 18 ms, 4–6 dropped4 / 6 / 10 ms, 9–10 of ~505
Stream only71 / 152 ms, 298 dropped71 / 95 ms, 55 of 9717 / 18 ms, 4–11 dropped6 / 9 / 11 ms, 21–25 of ~385
Scroll only17 / 19 ms, 32 dropped2 / 6 ms, 13 of 35917 / 18 ms, 2–4 dropped2 / 7–8 / 10–14 ms, 14–15 of ~388
Stream + scroll, screen recorded83 / 168 ms, 405 dropped78 / 94 ms, 66 of 9917 / 18 ms, 3 dropped3–4 / 4–5 / 6–7 ms, 0–4 of ~507

Before, the stream itself stalled: it delivered 116 text deltas in 8 s, against about 990 after.

Not yet a steady 120 fps:

  • Remaining long frames: 2–6% of frames in the stream-only and scroll-only runs still exceed 8.33 ms. They are mostly browser style and paint, plus Pierre writing highlighted code.
  • Stream-only trade-off: the warm range made stream-only slightly worse (17 frames over budget before it, 21–25 after). It keeps a viewport of rows mounted above the bottom.
  • Blank-frame count in stream-only runs: it counts the streaming row overflowing its last measured height (the content is drawn), not a gap.

Tests:

  • webviews/test/transcript-long-chat-updates.test.tsx (new) checks that a 300-turn chat draws each update as a fresh layout would: adjacent mounted rows sit exactly one drawn height apart, and the turn view matches a fresh one. It covers streaming with redraws, scrolling, a middle row drawing taller, a queued prompt, an edited answer far up the history, a dated turn and a turn ending. It also covers a registry change re-estimating undrawn rows, and a late answer previewing in its minimap turn. Each fix's test was committed red first.
  • bun run check passes.
  • bun test test/: 8 failures, also present on the base branch. Four need built bundle artifacts. Four are header pull-request tests that pass when run alone.
  • A review subagent found the registry and minimap problems, both now fixed. Randomized checks of OrderedRows against a full sort (3,000 runs) and of turnViewFrom against turnView (1,500 runs) matched.

Changelog

Fixed: Long agent chats scroll and stream smoothly instead of stuttering

Proof

The frame numbers above come from the fleet capture Mac; there's no visual change to capture.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes the agent pane's stutter in long chats: a 5,000-turn conversation (~25,000 rows, with large edit diffs and long shell logs) streamed replies and scrolled at ~12 fps. Every streamed delta re-sorted every row, reshaped every turn, and walked all rows through layout, drawn-height placement, minimap, session edits, and several memos; scroll frames also built and measured every newly visible Markdown, code, and diff card in the same frame.

Each pass now keeps its prior result up to the first row that changed (by object) and redoes only the rest, and rows one viewport around the reader mount in a deferred render between frames so scrolling finds them already drawn and measured. Frame work dropped from 78 / 154 ms p50 / p95 (61 of 100 frames over the 120 Hz budget) to 4 / 6 / 10 ms p50 / p95 / p99 (9–10 of ~505). A streamed reply delivered ~990 text deltas in 8 s versus 116 before.

Remaining long frames (2–6%) are mostly browser style and paint, including the highlighted-code writer. Left as-is: endedEditTurn compares row.kind to "editedFiles", a kind only rowKind() produces, so it never requested a checkpoint — as on base, only it no longer reads every row.

Testing

  • New debug-only harness seeds and streams through the real client and reducer (debug.agent_pane load_seed / load_run) and reports per-frame work.
  • New transcript-long-chat-updates.test.tsx checks that incremental updates draw exactly as a fresh layout would, including a registry change and a late answer joining its minimap turn.
  • bun test test/: 8 failures, also on the base branch (4 need built bundles; 4 header PR tests pass alone).