agent chat: a followed reply eases each new line into place instead of jumping a line per frame

Merged
TE
5d ago
+376-9

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):
    • followRoom returns the room under the last row. It is the height of the quiet live line when that line is the last row; O(1).
    • createFollowGlide provides one finite Web Animation at a time. A rise during a running glide starts from what is still drawn back.
  • App.tsx VirtualTranscript placement pass: after settleFollowBottom, 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 a follow block (per-frame row moves, overSnap > 8 px) and a settle block (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-motion now has eased-follow (the app's, new), steady-reveal (before), smooth-follow and subtle-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).

metricbefore (stepped)after (eased)deltaharness / run
per-frame reply move p50, Chromium22.75 px2.99 px−87%streaming-text-frames, 41187169912
per-frame reply move p95, Chromium50.75 px5.96 px−88%same
frames moving > 8 px, Chromium7121−70%same
per-frame reply move p50, WebKit22.75 px3.04 px−87%same
frames moving > 8 px, WebKit7224−67%same
end-of-turn settle moves / snaps / reversals0 / 0 / 00 / 0 / 00same
layout shifts above the tail000same
runway min (last line over the stack), Chromium−32.5 px−32.5 px0same
frame work p95 / max, Chromium7.8 / 10.8 ms8.3 / 13.5 ms+0.5 / +2.7 msstreaming-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