Agent chat: streaming replies grow by whole words, with room above the composer and no Markdown flips
A streaming reply in the agent chat should read like ChatGPT's or Claude desktop's. That means steady words paced to the token rate, and no block that draws one way and then flips as its Markdown forms. It also means room under the newest line instead of the line sitting on the composer's edge, and 60 fps on long turns.
Base already paces the reveal to the arrival rate (streamReveal.ts), parses incrementally and holds back half-written fences, tables and links (safeTail.ts). This PR closes the rest:
- Whole words. The reply grows a word at a time, and each word fades in. It never shows a letter or half a word at a time.
wordEdgecomes from the stalled #22339 (credit to its author). CJK text grows a character at a time. A word over 48 characters, such as a URL, shows as it arrives. The Debug toggleagentPane.debug.streamWords(on) switches back to characters. - The end of a reply and pauses. Until the whole reply shows, it keeps its streaming parse, so a code span or bold run that the last word closes never flashes its raw Markdown for a frame. When text stops arriving for 600 ms (
StreamReveal.stallMs, typically before a tool call), the held last word shows instead of waiting for the next delta. - No frames skipped in WebKit. The reveal loop used to skip any frame that came less than
COMMIT_MS - 1after the last commit. WebKit rounds frame times to whole milliseconds and they jitter around 17 ms, so on a 60 Hz display one frame in three to five stalled. The loop now learns the display's frame time and skips a frame only on displays faster than 60 Hz. A 120 Hz display still commits 60 times a second. There is now one loop per row, and it reads the latest text itself instead of restarting on every snapshot. - No Markdown flips. These no longer draw as text first and then turn into their block: a bare list marker (
-,2.), heading hashes before their space, a rule line, a lone*or_, and an open italic run. A fence closer still being typed is held back along with the newline before it, so a code card never shrinks by a line as it closes. - The closing fence moves nothing in WebKit. Pierre keeps a strip for its horizontal scrollbar under the code rows. That strip is 6 px in WebKit, so the highlighted card came out 6 px taller than the plain card it replaces, and everything under it jumped. The strip now hangs into the card's bottom padding.
- Runway. The transcript keeps 24 px under its last row, on top of the bottom stack's room, so the newest line sits about 32 px above the composer. The runway is always there, so a reply starting or ending moves nothing. It stays well under #23064's 96 px "no blank band" limit. With #23081 (chat-turn-codex-style's live status line), whichever PR lands second subtracts that line's measured height from the runway, so the two don't stack.
- Less main-thread work per frame. The page's reload copy of the transcript was serialized into sessionStorage on every snapshot, which is every frame while streaming. It is now written at most every 250 ms, and immediately when the page hides or unloads.
- Auto-follow is unchanged: the transcript follows only when you're within 4 px of the bottom (
FOLLOW_BOTTOM_DEADBAND_PX). The frame test checks that the pin holds while streaming.
Frame budget test
test/streaming-text-frames.test.ts streams a 5,000-character reply (streamingTextFixture.ts: paragraphs, headings, lists, a code block, a table). The reply goes through the real transcript, with the bottom stack drawn over it, in Chromium and WebKit, at Claude's and Codex's chunk cadences. It samples every display frame and fails on:
- any block above the one being written that moves or changes height
- any element that changes tag as it forms
- the pinned bottom drifting more than 4 px
- a frame's main-thread work over 16.7 ms at p95, or any frame over 50 ms, in either engine, on two runs in a row. Work is the script of every task between two frames plus the layout left for the frame.
test/browser/streaming-text.clock.tstimes React's scheduler tasks (renders, commits, effects and the layout they force), frame callbacks, resize observers, timers and queued microtasks. Work is wall-clock time, so a starved host can break it once. That is why it gets a second run, while shifts, flips and the pin fail on the first.
Frame intervals are reported but not held. On CI they measure the host as much as the page: shard 3 runs other dev-server browser files beside this one, and at 91e0ad32 one frame in twelve came 33 ms apart. The long frames are Pierre highlighting the closed fence on the main thread (about 120 ms in Chromium, 1.1 s in WebKit). That only happens on the test's dev page, which has no highlight worker; the app's page highlights in one (highlightPool.ts). That time comes off a frame's work, and the report lists those frames separately. Outside them, on cmux16 the intervals were p95 16.7 ms and max 16.8 ms in Chromium, and p95 19 ms and max 26 ms in WebKit.
Results on cmux16:
| before | after | |
|---|---|---|
| Elements that changed tag mid-stream | 2 | 0 |
| Gap from newest line to composer, p50 | 11 px | 35 px |
| Code card height change on closing fence (WebKit) | +6 px | 0 |
| Reveal frames with no step (WebKit, Claude / Codex cadence) | 72 / 121 | 0 / 0 |
| Reveal frames with no step (Chromium) | 0 / 1 | 0 / 1 |
| Frame work p95 / max, Chromium (both cadences, 3 runs) | 9.3-10.9 / 15-41 ms | |
| Frame work p95 / max, WebKit (both cadences, 3 runs; one 207 ms layout passed on its second run) | 6-7 / 15-26 ms |
test/code-handoff-height.test.ts checks that the plain and highlighted cards are the same height in both engines. test/streaming-markdown-forming.test.ts checks every prefix of the fixture for flips. test/snapshot-save-throttle.test.ts covers the 250 ms reload copy.
Gallery
The gallery entry agent-pane.streaming-text streams the frame test's reply at both cadences. The Linux gallery run takes its stills. These recordings come from cmux16 at fca0cb22, which has the same tree as 91e0ad32.
Claude cadence, Chromium:

Full quality:
Codex cadence, WebKit:

Full quality:
No capture: the only native change is a Debug Settings toggle (agentPane.debug.streamWords, on by default). Everything visible happens in the webview transcript, which the gallery recordings above show.
Changelog
- Changed: Agent chat replies stream in steadily a word at a time, blocks no longer change shape as their Markdown forms, and there is room between the newest line and the composer.
Observed / Not run
- Red/green on cmux16:
cd webviews && bun test src/agent-session/acpmux/conversation/RevealedMarkdown.test.tsx. At the test commit 24956479 the three new tests fail (the code span shows "Run `bun", the paused reply stays at "Let me check the",wordEdgereturns 53 for 60) and the other five pass. At fca0cb22 all 8 pass. - CI at c98ae836:
test/streaming-text-frames.test.ts,test/code-handoff-height.test.tsandtest/agent-pane-recap-card.test.tspass. TheCmuxNextAgentPanemodule, where the tunable lives, compiled in the fast tier. Every remaining red is also red on base:RemoteChildSessionsTestsdoesn't compile (fixed on base by #23362).- The "Companion workflows" check is missing
base-autofix-repair.sh. agent-pane-scrollbar-full-height's two "grows and shrinks" tests end with the composer card 20 px off its starting height. They failed 3 of 3 times at basebc70489aon cmux16.
- At 11a3ffba,
agent-pane-recap-card's fit test failed because of this PR: the pane was sized for a chat with no runway. c98ae836 fixes it. - Gate on cmux16 at 11a3ffba (review fixes: highlight time counted once, scroll listeners timed):
bun run checkpasses, prepush-gate passes,test/streaming-text-frames.test.tspasses 4 of 4 at load 28-37 with no retry. Work p95 / max was 10 / 15.2 ms (Chromium, Claude), 9.6 / 22.9 ms (Chromium, Codex), 7 / 25 ms (WebKit, Claude) and 6 / 30 ms (WebKit, Codex). Chromium's only long task (119-123 ms) is Pierre tokenizing the closed fence. - Gate on cmux16 at e2875bd8 (base merged at 8214250a; its one conflict was
rowsHeightinApp.tsx, where #23097's heldroomreplaceddockSpaceand the runway stays on top):bun run checkpasses, prepush-gate passes, the 54 unit tests pass, andtest/agent-pane-scrollbar-full-height.test.ts(#23097's tests) andtest/code-handoff-height.test.tspass.test/streaming-text-frames.test.tspassed 4 of 4 at load 20-33: 0 shifts, 0 flips, pin 1 px. One WebKit Codex run went over the work budget once, with a 207 ms forced layout, and passed on its second run. An earlier run at load 25-36 failed two engines on long tasks of 540-650 ms with a grammar load taking 850 ms (normally 20). That run predates the retry. - Gate on cmux16 at df8c327e: typecheck and
vp lintpass, prepush-gate passes, the 54 unit tests pass, andtest/code-handoff-height.test.tspasses in both engines.test/streaming-text-frames.test.tspasses 4 of 4 at load 8-20: 0 shifts, 0 flips, work p95 7.1 ms in Chromium and 4 ms in WebKit.bun run checkfails onlycheck:switch-fetches, oncomposer/keptAttachments.ts(from #23139, already on the base 9912487f; this PR doesn't touch it). - At 91e0ad32, CI's react-apps-check (3) failed the frame test in Chromium: a 300 ms frame and a 33.3 ms p95 interval. Everything else held: 0 shifts, 0 flips, pin 1 px. df8c327e changed the timing budget to per-frame work. Review of df8c327e found that its work figure missed commit-time layout, layout effects and resize observers, e2875bd8 times every task instead, and 11a3ffba fixes the second review's findings. CI never ran df8c327e, because the PR conflicted with base.
- Earlier gate on cmux16 at fca0cb22: typecheck,
vp lint,bun run check, prepush-gate, the 54 unit tests this PR touches,test/code-handoff-height.test.ts(both engines) pass.test/streaming-text-frames.test.tson cmux16 at load 36-98 had 0 layout shifts and 0 tag flips in Chromium, but missed the timing budgets (one 10 s frame, after which the view rested 326 px from its end for 2 frames), and WebKit timed out at 180 s. That is host load; PR CI's browser lane runs this test.scripts/web-lint.shcould not start there (nodeis not on cmux16's PATH); itsvp checkpassed in the check step and CI runs it. - The Swift tunable test was added but not run locally (no Mac builds here). CI compiles it.
- Frame timings on cmux16 are noisy (load ~130). Chromium max-frame spikes of 100 to 300 ms appeared at both base and head while the host was loaded. The Chromium budgets are meant for the CI runner.
Follow-up
incrementalMarkdown.ts'sfenceOpenis now read only bytest/incremental-markdown.test.ts.- The gallery entry's
drawframe loop is not cancelled on unmount (gallery only). model.tsestimates a wide code block's scrollbar at 15 px (SCROLLBAR_HEIGHT), which is too high now that the strip hangs into the padding. The virtualizer corrects it on measure, but the estimate should match.
Leo-ask: ask-76c0ef9f1c (this PR adds the 24 px runway under a streaming reply. The caret was already removed by #22730, words already fade in (RevealedMarkdown FADE_MS), and growth at the latest row already glides into place)
Agent session: cosmic-gray
🤖 Generated with Claude Code