Agent chat: new lines in the composer leave the transcript's scrollbar still
Summary
Leo reported (2026-10-20): "the new line situations in the composer make the scrollbar jump".
At the latest row, each Shift-Return or wrap grows the composer card by 20px. While the transcript follows, the room under the last row was the bottom stack's whole height. Each line grew the transcript's content height by 20px, and the placement pass wrote the scroll offset to the new end. The written offset showed the overlay scrollbar's thumb, and its size changed on every line (602px tall, then 593, and so on). Deleting lines reversed it. Scrolled up, nothing moves; #23097 covers that and still does.
Captures at base 79ff18fe: , frames.
The fix: while the view follows, the composer card's growth since it began following stays out of that room. The rows are drawn up by the growth with a translateY on .acpmux-thread instead. The last row keeps its spot above the card, and the scroller's offset, content height and height stay as they were, so the scrollbar doesn't show or change. Only the card counts. The rest of the stack (a queue, an ask) makes room as before.
- How far it lifts: at most what the offset can take back above the follow deadband (
atDock - height - FOLLOW_BOTTOM_DEADBAND_PX - 1). The rest of the growth is real room. In a transcript about a screen tall, a scroll up still leaves the latest row and the first rows stay in reach. Room held for a sent prompt (floor) already covers the card, so that part never lifts. The lift and the held room use the same rounded value. - Leaving the latest row (a wheel up): in the same frame, the lift becomes room and the offset moves by it, so nothing on screen moves. The scrollbar changes once there, while the reader is scrolling.
- Writes by a row's place (Find's target, the run line's reveal, keyboard focus of an unmounted row): these check "already in view" against where the row is drawn. When they do scroll, they first stop following and drop the lift, then write the row's layout position.
- A queued prompt sent while the card is grown: the room held for the prompt (
floor) is kept as the content end with all the stack's room, and the content end is that less the lift. While the held room reaches past the rows, the lift on screen stays as it is. Before, the held room was taken from an end that already had the lift subtracted, and it capped the lift. So the queue card leaving shrank the lift and the transform alone moved every row down by the card, then back up as the prompt arrived. - Back at the latest row: the room is the stack's again. A sent or cleared draft shrinks the card and the transform returns to 0.
- Reduce Motion:
.acpmux-threadopts out of the shared 0.01ms transitions, as.acpmux-spaceralready does, so its transform never trails a frame. - Unchanged:
scrollRangeIn, the fold anchor and the wheel pass-through read rects or move the offset relative to where it is. The glide usescomposite: "add", so it stacks on the transform.jumpToLatestis untouched.
Follow-ups, not in this PR:
- While the lift is capped (a transcript about a screen tall), the cap grows as a reply streams, so new text moves through the transform with no glide.
- When following began with a long draft that's then sent, the lift goes negative and leaves blank scrollable room under the last row until the view stops following.
- The thread minimap's jumps, steps and scrubs (
ThreadMinimap.tsxjump,scrollMotion.ts,ScrollMap.tsxseek) write layout-position offsets while the rows are lifted. A minimap jump from a followed view with a grown draft lands off by the lift (at most the card's growth) once following ends. - The minimap's
viewportHeight(height - room) shrinks by the lift while following, so its band can be off by the card's growth.
Changelog
Fixed: In agent chat, typing new lines in the composer no longer makes the transcript's scrollbar appear and resize.
Testing
cd webviews && CMUX_BROWSER_TESTS=1 bun test test/agent-pane-scrollbar-full-height.test.ts
New tests:
- "a composer that grows and shrinks at the latest row keeps the scrollbar still on every frame": every painted frame while five lines are typed and removed must keep scrollTop, scrollHeight and clientHeight (within 0.5px), with the last row above the card and the view at its end.
- "a reader who scrolls up from a grown composer sees the rows move only by the wheel": after five lines, a 300px wheel up; on every frame a fixed row moves only down and by no more than the wheel.
- "a transcript about a screen tall keeps its first row in reach while the composer grows": three turns, the view sized 1-4px shorter than the content; after five lines and a wheel up, the first row must be in view.
- In
agent-pane-stream-jitter.test.ts, "a queued prompt sent while the composer is grown moves the view up only" (Chromium and WebKit): with a queued prompt and five lines typed, the turn ends and the queue sends the prompt; on every painted frame the reply may only move up.
Red, first round, test-only 5f253def66b7 (react-apps-check (1)): the scrollbar test failed. scrollHeight and scrollTop moved with the card on every line: card 82 → 6160 / 5504, up to card 142 → 6220 / 5564, then back down.
Green, first round, fix 954cf8c876bf (react-apps-check (1)): the scrollbar test passed.
Red, review round, test-only 4f95c0150a9c (react-apps-check (2)): "a transcript about a screen tall" failed, with the first row 84px above the view top while scrollTop was 0, scrollHeight 589 and clientHeight 586. The view couldn't scroll up to it. "A reader who scrolls up" passed at that SHA: the conversion from the first round was already right, and this test now guards it.
Green, fix 0738fb176d73: the whole file passes (react-apps-check (4), and on the rerun, job 119228012737). That includes the two older tests that failed their card-height check on the test-only SHAs.
Red, second review round, test-only 63422b92aceb (react-apps-check (4)): the new queued-prompt case failed in both engines. The reply dropped about 19px over five frames as the queue card left (30.1 → 36.7 → 40.6 → 43.0 → 44.6 → 46.4).
Green, fix e98a1c688776, at the merged head 5f5ec9b19f11 (after #23257 and other base changes merged in): agent-pane-stream-jitter.test.ts passes in react-apps-check (3) and agent-pane-scrollbar-full-height.test.ts passes in react-apps-check (4). No test failed in any shard. On the earlier head 9476b4c42d4e, the queued case passed in both engines on the shard's first run (which timed out at the 120s file limit, so the file now declares 200s). In the shard's lone retry, Chromium showed one 1.6px step down (122.9 → 124.5), compared with about 19px before the fix.
The new tests wait for conditions (painted frames, the offset at rest, the queued card or reply text showing), not fixed times. Calls to the files' shared open() and stream() helpers say why they stay, as the fixed-wait ratchet (#23365) asks.
Earlier reds on the fix head that this change didn't cause:
- WebKit "scrolling up while a reply streams is never pulled back" (
agent-pane-stream-jitter.test.ts) and a timeout incomposer-draft-wrap.test.ts, in attempt 1. Both passed when the failed jobs were rerun once at the same SHA. - The cmux-next fast tier: Swift tests in
Packages/macOS/CmuxNextdon't build ("call can throw, but it is not marked with 'try'"). - The gallery visual evidence capture, which is advisory.
Observed / Not run
On the merged head 5f5ec9b19f11 the remaining reds are base lint findings in files this PR doesn't touch, shown in react-apps-check (1)'s gate step and cmux-next fast gates: check:fixed-waits (RevealedMarkdown.test.tsx, agent-pane-queued-images, agent-pane-reveal-message, streaming-text harness and frames, snapshot-save-throttle, and session-sidebar-preview's baseline) and check:browser-lanes (diff-paint-perf.test.ts). Also red: cmux-next guards defaults-snapshot ("Menu rows run registered actions"), which this PR doesn't touch.
Observed: the CI shard logs above (Chromium, plus WebKit for the queued-prompt case). scripts/web-lint.sh passed on every changed file except the base red check:text-focus in timeline.css, and scripts/cmux-next/prepush-gate.py passed.
Not run: this file doesn't run WebKit (the new tests are Chromium-only), and the real app wasn't re-captured, so the fix hasn't been seen in WebKit or the app. No local browser tests ran (the fleet rule for this Mac). Keyboard focus of an unmounted row, Find and the reveal while lifted are read in code, not tested.
Agent session: cosmic-gray
🤖 Generated with Claude Code