Agent chat: the view stays pinned to the latest line as it grows, and a send always lands at the bottom

Merged
TE
15d ago
+432-166

Leo-ask: ask-4489fa273d

Leo, 2026-10-21: "WHY IS THIS NOT IMMEDIATELY PUTTING ME AT THE BOTTOM EVERY SINGLE TIME", and "I LOAD IN AND IT'S BLACK SCREEN AND THEN THIS ADJUSTMENT THING GETS FUCKED".

When the agent chat is at its latest message, new content (streamed text, tool rows, incoming messages) now keeps it pinned to the bottom in the frame that draws it, and sending a prompt always puts the view at the bottom, even if you'd scrolled up. Only scrolling up yourself unpins it.

What was wrong, in VirtualTranscript (webviews/src/agent-session/acpmux/App.tsx):

  • While following, every growth started a "growth glide": the thread was animated from translateY(+step) back to 0 over 180 ms (MOTION_MS.standard). Each streamed chunk or tool row first drew below the view's foot, under the composer, and then slid up. Chunks arrive faster than 180 ms, so a streaming reply was almost always mid-glide. The glide and everything that existed only for it (glide, GlideHold, trailingRowTop, reflowing, resized, the glide-ending listeners) are gone. The followed offset now moves in the layout pass that lays the growth out, before paint.

  • Nothing reacted to a send. App's chat.send now bumps a sends count, which is passed to VirtualTranscript. A layout effect then puts the view at the latest message before the next paint, clears any pending reveal, fold anchor or jump, and starts following again, so the prompt's row and its reply come in at the bottom.

  • A send while the chat is still opening (paging back, unseen, to the reader's saved place, or a tail that still fits) stops that walk and opens the chat at its latest message. Before, the send was counted and dropped, and the chat then opened at the old place.

  • The subagent view shown in the chat's place takes no sends: a prompt goes to the chat. Switching chats (or to and from the subagent view) resets the count the view has seen, so it is never read as a send.

  • With the glide gone, a lag frame it used to hide showed: about one streamed frame in eight painted the reply's newest line 13-37 px below the view's foot. The rows' ResizeObserver measured the rows and then rendered (flushSync), and that render also committed the reply's next reveal step, so the reply grew after it was measured. The observer reports a target at the same depth once a frame, so the new height came a frame late. The observer now measures its rows again after each render until they hold (three passes at most).

The tab-switch paths (sessionSwitched, SWITCH_HOLD_MS) aren't touched.

The 23 px turn-end jump on the first CI run

The review blocked on agent-pane-stream-jitter › "a turn that ends leaves its reply where it was" (Expected: <= 0.5, Received: 23, both engines). The jump is not the turn's end. It is the reply's last line being drawn while the test reads frames:

  • A streamed reply keeps revealing for about 200 ms after its last chunk (StreamReveal trails the stream; each revealed step fades in as .cv-fresh). The test's rest only waited for the offset to hold 6 frames with no glide on the thread. The offset holds that long between two wrapped lines, so rest returned mid-reveal (traced: rest returned at 3187 ms with the reply at 333 of its 359 characters, done at 3243 ms).
  • If the rest of the reveal wraps one more line, the followed view moves up one line, 22.75 px, inside the sampled window. With the CI runner's fonts the 20-chunk reply most likely wraps there (it failed the same way in both engines and on retry). On huge-blue it doesn't, and a 21-chunk reply reproduces Received: 23 in Chromium at the old head.
  • On base the same rest passed only by accident: every wrapped line started a 180 ms glide on the thread, and waiting for the glides outlasted the reveal.
  • At the turn's end itself nothing moves: the running line and the footer that takes its place are both 32 px tall, and the reply stays at the same top across the swap in both engines.

So the fix is in the test: rest now also waits until nothing in the transcript animates (finite animations only: the reveal's fades and a row's entrance; a spinner or the running line's shimmer loop forever and don't count). With it, the 21-chunk reproducer passes in both engines. The test still catches a real turn-end jump: with the footer made 14 px taller, it fails with Received: 14 in both engines.

Comments that still described growth gliding are updated in App.tsx, agent-pane-chat-column.test.ts, agent-pane-scrollbar-full-height.test.ts, agent-pane-follow-live.test.ts and agent-pane-stream-jitter.test.ts. agent-pane-chat-column.test.ts drops its "never drawn at once" (snapped) check and its placed/glided fields, which asserted the glide; its "never down and back" and "never backwards" checks stay.

Changelog

  • Fixed: The agent chat stays pinned to the latest line while a reply streams or tool calls come in, and sending a prompt always takes you to the bottom.

Test

webviews/test/transcript-pinned-bottom.test.ts (Chromium and WebKit, through test/browser/transcript-follow.harness.tsx, which gains window.__send and window.__openAt):

  • growth stays pinned with no lag frame: a long chat opens at its bottom, and a reply streamed a chunk per frame plus a tool row keep it there, with no glide on any painted frame. Every frame that resized anything is read at its last ResizeObserver callback, right before it paints, and must be at the end (red before the observer fix: 13-14 px in both engines);
  • scrolled up, growth leaves the reader where they are (scrolling up unpins), and a send puts them at the bottom on the next painted frame;
  • a send while the chat pages back to the reader's saved place opens it at the bottom.

webviews/test/agent-pane-stream-jitter.test.ts covers the turn's end: "a turn that ends leaves its reply where it was".

cd webviews && CMUX_BROWSER_TESTS=1 bun test test/transcript-pinned-bottom.test.ts test/agent-pane-stream-jitter.test.ts

Observed / Not run

Observed, on huge-blue at d59f0fcd044 (feat-cmux-next 0b71d5ea640 merged in) with CMUX_BROWSER_TESTS=1, all passing: transcript-pinned-bottom 6/6, agent-pane-stream-jitter 18/18, agent-pane-switch-frames 3/3, agent-pane-cached-chat-open 10/10, chat-open-long 2/2, agent-pane-chat-column 13/13, agent-pane-follow-live 6/6, agent-pane-scrollbar-full-height 10/10, agent-pane-history-paging 2/2, agent-pane-reading-place, agent-pane-bottom-stack, agent-pane-turn-footer, transcript-fling, and all other transcript-* files. vp fmt, bun run check and check-frame-reads pass.

Red the same way with base's App.tsx (0b71d5ea640), so not this PR's: agent-pane-older-rows-fade, transcript-thought (a vite build timeout), and agent-pane-jump-latest-motion's "a reply streaming in during Jump to latest's glide". chat-open-perf's 600 ms cold-draw ceiling fails intermittently on the loaded host (load 30-60), 3 of 6 runs either way with and without the observer fix.

In this harness, headless WebKit on Linux doesn't scroll for Playwright's mouse.wheel, so the scroll-up test sends a wheel event and then moves the offset itself.

Not run: the app itself (no tagged build or dogfood).

No capture: webview-only change; the browser tests above cover it.

Agent session: Air-Blue

🤖 Generated with Claude Code