agent chat: a followed reply eases each new line into place instead of jumping a line per frame
What
A followed transcript used to move a streaming reply up a whole line in a single frame each time it grew a line (22.75 px at the chat font, and up to 92 px for a heading or code card). Now each rise eases into place over the standard 180 ms ease-out (MOTION_EASE_OUT).
The follow logic is unchanged: the view's offset still lands at the bottom in the same pass, before paint. Only what the reader sees eases. A FLIP on .acpmux-thread draws the thread back by the rise and animates translate to 0 on the compositor. No row is laid out again and no scroll event fires. The draw-back is capped at the room the running turn's hidden live line keeps under the reply (META_ROW + TURN_GAP, about 40 px), so new text never lands under the bottom stack. #23807 removed an older glide because it held new text below the view's foot; this design avoids that.
These move at once, as before: falls, rows that come or go, resizes, Jump to latest, Reduce Motion, and cmux motion off (data-agent-motion="off" or a 0 ms --agent-motion-move).
Leo's words:
- "i feel like the text streaming feels just a little jumpy... the animation or lack thereof it's a bit... idk,"
- "also i just noticed this jump like i think we're mildly optimistically giving the response a gap... but then after you wrote stuff and finished, it ended and then it snapped back after it realized it didnt need that much space..."
- "i also wanted to make sure agent responses the way they stream there'd be more than enough space at the bottom or something so it's a smooth graceful descent rather than more jittering"
On the end-of-turn snap (second quote): #24765 removed the permanent runway, and #24805 keeps the footer's geometry through the turn settle. On this base the settle measures 0 moves, 0 snaps and 0 reversals both before and after this change. The new test pins that, so the snap stays gone.
Changes
conversation/followGlide.ts(new):followRoomreturns the room under the last row. It is the height of the quiet live line when that line is the last row; O(1).createFollowGlideprovides one finite Web Animation at a time. A rise during a running glide starts from what is still drawn back.
App.tsxVirtualTranscript placement pass: aftersettleFollowBottom, a rise eases only when all of these hold:- the view was at the same size and dock space in the previous pass;
- the row count is the same;
- the second-to-last row is the same;
- no jump is in flight.
- A resize (width, height or dock) stops a glide in flight, and none starts until the view has held its size for 180 ms; the passes that settle rows to a new width are part of the resize (agent-pane-chat-column).
- The rise is measured in layout coordinates, so the sub-pixel scroll origin from #27365 never counts as a rise.
stop()runs on reader input, Jump to latest (stopJump), chat switch and unmount.- Harness (
test/browser/streaming-text.harness.tsx): adds afollowblock (per-frame row moves,overSnap> 8 px) and asettleblock (moves, snaps and reversals after the last growth). It also gains an option to run with the live line under the reply (the app default), eased or stepped. - Gallery
agent-pane.streaming-follow-motionnow haseased-follow(the app's, new),steady-reveal(before),smooth-followandsubtle-fade.
Gallery: agent-pane.streaming-follow-motion/eased-follow, agent-pane.streaming-follow-motion/steady-reveal, agent-pane.streaming-follow-motion/smooth-follow, agent-pane.streaming-follow-motion/subtle-fade
Performance
The same harness and the same page run the same 1,800-character Claude-cadence stream twice, stepped (before) and eased (after), with the live line under the reply. Source: streaming-text-frames.test.ts › "a followed reply rises line by line without jumps", run 41187169912 at this head's tree. Frame-work numbers come from run 41177768998 (same test, before the rebase onto #27365).
| metric | before (stepped) | after (eased) | delta | harness / run |
|---|---|---|---|---|
| per-frame reply move p50, Chromium | 22.75 px | 2.99 px | −87% | streaming-text-frames, 41187169912 |
| per-frame reply move p95, Chromium | 50.75 px | 5.96 px | −88% | same |
| frames moving > 8 px, Chromium | 71 | 21 | −70% | same |
| per-frame reply move p50, WebKit | 22.75 px | 3.04 px | −87% | same |
| frames moving > 8 px, WebKit | 72 | 24 | −67% | same |
| end-of-turn settle moves / snaps / reversals | 0 / 0 / 0 | 0 / 0 / 0 | 0 | same |
| layout shifts above the tail | 0 | 0 | 0 | same |
| runway min (last line over the stack), Chromium | −32.5 px | −32.5 px | 0 | same |
| frame work p95 / max, Chromium | 7.8 / 10.8 ms | 8.3 / 13.5 ms | +0.5 / +2.7 ms | streaming-text-frames, 41177768998 |
Frames still over 8 px come from blocks taller than the live line's room (a heading, a code card). Those take the remainder at once.
Timers and animations: one Web Animation on the thread, at most one at a time.
- Stop condition: it is finite (180 ms, or the host's move token).
- Cleanup:
stop()cancels it on reader input, Jump to latest, chat switch and unmount; a new rise cancels the previous one;fit(room)stops it once the live line comes into view (its room gone), so that line never sits under the bottom stack (from the Astra review). - No timer, observer or RAF loop is added.
- Per-token cost is zero: a rise happens only when the rows' height changes, and costs O(1). No O(rows) work per token.
Risk
- Medium, limited to the followed view while a reply streams.
- In WebKit, the frame in which a new block draws before its row is measured can sit up to one line lower under the stack than stepped (−54 vs −34 px, one frame). That frame was already under the stack before this change; the test bounds it at one line.
- Turning motion off or Reduce Motion restores the stepped behaviour exactly.
- Tests run on cmux-ci (Blacksmith, Chromium and WebKit), all green: agent-pane-chat-column (13), popover-instant-open (8), streaming-text-frames (6), agent-pane-stream-jitter (20), transcript-follow (4), transcript-pinned-bottom (6), agent-pane-follow-live (6), transcript-parked-stream (3), transcript-upward-anchor (6), transcript-jump-latest (5), followGlide unit (4), tsc, lint, fmt, check:fixed-waits.
Design spec: design/chat-transcript.md (leo-workspace) gains a "Streaming reply motion" section with these rules.
Agent session: agent session
🤖 Generated with Claude Code