Agent chat: long lines in diff and command cards show a thin scrollbar at rest

Merged
TE
13d ago
+1034-58

Leo-ask: ask-d900f5acf9

In agent chat, an unwrapped diff, Shell block or call output whose lines run past the card's right edge now keeps a thin scrollbar thumb under its lines at rest, as long as there is more to scroll. Before this, the only sign at rest was the right-edge fade from #23413: the diff's thumb stayed transparent until you scrolled it (and was a 1.5 to 7.5% tint while you did), and Shell and output bodies used WebKit's overlay scroller, which only shows while scrolling. A card cut to its eight-line preview now ends with that scrollbar in view. Before, a cut diff's scrollbar sat under its last line, below the cut.

I picked "scroll with a visible thumb" over always wrapping. Prose files (.md, .txt) and command output already wrap by default since #23413, so Leo's codex-excursions.md card now wraps. Code stays unwrapped because wrapping breaks its shape, and the Wrap lines toggle covers anyone who wants it. The visible thumb follows Leo's newer call on the composer (#23854): "IF THERE ARE THINGS TO SCROLL, JUST KEEP THE SCROLLBAR". It is a thumb with no track, so SCROLLBARS-THIN's "never a permanent gray track" still holds.

What changes

  • webviews/src/scrollers.ts
    • watchSideways sets SIDEWAYS_ATTRIBUTE on a block's scroller while its lines run past it.
    • On Pierre's code rows, that attribute gives the thumb its at-rest color, --cmux-scroller-thumb. It keeps the same color while scrolling.
    • Shell and output bodies carry SIDEWAYS_SCROLLER_ATTRIBUTE from their JSX. SIDEWAYS_SCROLLER_CSS, installed with the page's other scroller rules, makes their scrollbar the thin 8px one from the start. The attribute has to be there from the start because WebKit keeps a scroller's kind once it has made it.
  • conversation.css and sideways.ts
    • A cut preview grows by the scrollbar lane (--cv-lane) wherever it keeps one (see the review fixes below), and its bottom fade stops above the lane.
    • A cut diff also cuts Pierre's code box (--cv-block-cut), so the box's scrollbar sits at the cut instead of under the hidden lines.
    • Pierre's lane is pinned at its current 6px through --diffs-scrollbar-gutter-override inside diff cards; the new browser test reads it as 6px there.
  • Gallery: new states edit-long-line-code-cut and shell-unwrapped-long-cut (and the review states below).

The resize cursor

I found no resize cursor or resize: property in any transcript card. The only col-resize in the agent pane's webview is the inspector's own edge (inspector/inspector.css:53), and it isn't over the chat column.

The cause #23413 found is native. DividerHandleView.resetCursorRects added its whole frame as a cursor rect, so a strip divider running under a docked chat kept its resize cursor over the chat. AppKit cursor rects ignore views stacked above them. #23413 fixed it with cursorClip (merged 2026-10-21 15:59 UTC, after this screenshot was taken on 10-20 21:57), and that fix is unchanged here. The new browser test adds a guard: across a grid over each card and its tool row, nothing shows a *-resize cursor or has a resize value.

Tests

  • webviews/test/transcript-block-sideways-scrollbar.test.ts, in real Chromium (scrollbars on) and WebKit, on the long-line gallery states, cut and whole. For each block it checks:
    • the lines overflow by more than 100px;
    • the scroller's lane is 1 to 10px tall and sits inside the card's shown area;
    • at rest (pointer away, 1.2s after the last scroll) the lane draws a thumb and no full track. It compares the lane's pixels with the same lane once every thumb is made transparent;
    • no resize cursor shows anywhere over the card or its tool row.
  • transcript-block-overflow.test.tsx (JSDOM) checks that the new attribute is on both kinds of scroller, stays after scrolling to the end, and is off when the lines fit. It also checks that the bottom fade ends above the lane.
  • Why they fail on base: the diff's thumb is transparent at rest; the Shell body's lane is 15px (Chromium's classic bar) or absent (an overlay scroller); a cut diff's lane lies below the cut; and the JSDOM test's attribute doesn't exist yet.

Focused command: cmux-ci run --class light --script scripts/ci/run-webviews-tests.sh --env CMUX_BROWSER_TESTS=1 --env CMUX_WEB_TEST_FILES=<the two files, newline-separated>

  • Green at head b0d426c89aac8080844e996b2fed30e4cef4b77c: CI run 39738984052 (react-apps-check) passed both files in Chromium and WebKit.
  • Red without the fix: a Chromium-only fleet run of the tests on base showed the Shell body with no lane (lane: 0) and the cut diff's lane below the cut (535.75 vs 427.75). The JSDOM test fails on base because the attribute doesn't exist. There is no red run of the final pixel (thumb-at-rest) check: the fleet's WebKit didn't launch (180 s timeout), and the last red/green pair sat queued for about 4 hours before it was cancelled. An earlier red for the diff thumb used a detection method that has since been fixed, so it isn't counted as evidence.

Codex review fixes (rounds 1 and 2)

  • Work per streamed token (sideways.ts):
    • Only mount walks a block.
    • A change marks the code box it happened in, takes in only the nodes it added, and lets go of the ones it removed.
    • Marked boxes are measured at most once per animation frame. Scroll and resize still update straight away.
  • Leak (sideways.ts): a removed Pierre host now takes everything under it with it: its shadow root's change observer, its code boxes, their size observations and their scroll listeners. A host removed before its shadow root arrived is dropped too.
  • Stop conditions: the returned stop function cancels the pending frame, disconnects every change observer and the size observer, and removes every scroll listener.
  • Lane height (conversation.css):
    • A wrapped diff has no lane, so a cut preview is exactly eight rows.
    • A call output's lane is its own 8px bottom padding, which the scrollbar takes when it appears, so the output is the same height with or without it.
    • An unwrapped Shell body keeps the 8px lane while its lines run past. While its command is still running (data-live, from the row's running state), it keeps the lane whether or not they do, so a streamed line that starts or stops running past doesn't move the transcript.
    • End of a stream for a block that fit: when the command ends, the body drops its 8px lane once, at the same moment the row changes from running to done. A settled block whose lines fit has no empty tail, and its height doesn't change after that.
  • New tests:
    • JSDOM: a streaming test (200 blocks, 50 tokens into one; only that block is measured, once per frame, with fewer than 20 elements walked). A leak test (six host removals, half with a late shadow root, then a host removed before its root; afterwards the scroll listeners, size-observed elements and observed roots are back to their mount counts, nothing detached is measured, and stop releases everything).
    • Browser: a diff (whole and cut), a running command (whole and cut) and a call output keep the same body and card height while their lines stop and then start running past again. Settled Shell bodies (whole and cut) and the call output, once their lines fit, are as tall unwrapped as wrapped, with no empty tail. Wrapped diffs (cut and whole) have no lane, and a cut one is eight rows.
    • New gallery states: shell-live-unwrapped-long-line, shell-live-unwrapped-long-cut, output-unwrapped-long-line.
  • Red/green for the review fixes, both test files, Chromium only (the fleet's WebKit doesn't launch). Red is the tests without the fix.
    • Head 0056f5ebb6c5a922b2ef4aabd3ba8c7deddc0e7e (the round-2 fix): red on fleet step cc54a7b19da8c140501681fa and on huge-blue (the leak test, the wrapped-diff tests and the settled-Shell tests fail); green on fleet step b0fb5d3a6e488d7e4cb1e202 and on huge-blue (both files pass).
    • Round 1: red on fleet step 626a6ed87c59d17c9aa16244 (old code measured during the stream; the Shell body shrank by 8px when its scrollbar went), green on 6f03d1c010974893bc1e4cef.
    • The live-Shell height tests also pass on the round-2 red, because round 1 already kept the lane on every unwrapped Shell. Their round-1 red is the 8px shrink above.

Codex review fix (round 4)

  • A non-shell call output past eight lines is cut (.cv-tool-output[data-preview="cut"]). Its cap was calc(8 * 18px) under box-sizing: border-box, so its 8px top padding and its 8px scrollbar lane came out of the eight lines: the box showed 128px of text, about seven lines, and the lane did not make the preview taller.
  • conversation.css: the cap is now calc(8 * 18px + 8px + var(--cv-lane)), eight lines plus the top padding plus the lane, the same shape as the Shell body's cut (calc(8 * 18px + 8px + var(--cv-lane, 0px))).
  • New gallery state output-unwrapped-long-cut (a 14-line call output, unwrapped). It joins the browser matrix (thumb at rest, steady height, settled), and a new check reads the cut box's clientHeight less its padding: it must be eight line heights (144px), both while its lines run past the edge and after they fit.
  • Red at 3a3205b41deaf7f12106788b1e5bc1f3b574b20f (the test only), fleet step 51ffedc3fa3763c2100a4750 on cmux12s: only the new check fails, in Chromium and WebKit, shown: 128 against eight: 144 in both states; the other 36 tests pass.
  • Green at 5dc8099164be1d83c3b92cf409f478c62db0d27c (fix plus base merge): fleet step 543b4b5589718161282a6142 on cmux13s (both test files) and 18280c8b7fbdc3ea3f5fbb24 on cmux12s (the browser file), Chromium and WebKit.
  • Base merge of feat-cmux-next (ea5f81f037b): one conflict, in ToolRow.tsx. Base added data-lang to the call output's <pre> and this branch added the SIDEWAYS_SCROLLER spread on the same line; both are kept. Base's new Markdown result box (.cv-tool-output__markdown) is not a .cv-tool-output, so the lane and cut rules don't reach it.

Observed / Not run

  • Observed: scripts/web-lint.sh passed; scripts/cmux-next/prepush-gate.py found no new violations; check-scrollbars.sh reported nothing in webviews; the branch has feat-cmux-next merged in (it picks up #23935's ContextSection.css and the wave.css fixes).
  • Reds at 5dc8099164b that are not this PR's: rust workspaces (Native/OptChat/chief-turn has no entry in .github/rust-workspaces.toml on base; this PR touches no Cargo workspace), and fast gates' web lint, whose only "new finding" is vp lint's closing line Finished in 7.6s on 2465 files with 162 rules using 4 threads.: the compare's noise rule (scripts/ci/fast_gate_webviews_checks.py NOISE, #24436) matches on N files using N threads but not the with N rules form, and the merge's file count differs from the base's. The base's own 4 lint findings are reported as base.
  • Earlier reds also red on base: gallery health (2) (red on feat-cmux-next 0ce6802c01 and on other open PRs), release-notary and Guard status (base), and cmux-next checks (package-conventions and job-structure steps; this PR touches no file they check).
  • Not run: no tests ran on this Mac (air-blue). The app wasn't built or tried, so I haven't confirmed in WebKit-in-app that the thumb shows at rest under the "When scrolling" and "Always" settings. The native divider fix from #23413 wasn't re-checked in the app. Fenced code cards in replies (HighlightedCode) still show their scrollbar only while scrolling; they're outside this change.

No capture: webview-only change; the gallery advisory run captures the new and updated agent-pane.transcript states (edit-long-line-code, edit-long-line-code-cut, shell-unwrapped-long-line, shell-unwrapped-long-cut, shell-live-unwrapped-long-line, shell-live-unwrapped-long-cut, output-unwrapped-long-line, output-unwrapped-long-cut).

Changelog

  • Fixed: In agent chat, diff, command and output cards whose lines run past the right edge now show a thin horizontal scrollbar at rest, including when the card is cut to its preview.

Agent session: Air-Blue

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Agent chat diff, Shell and call-output cards whose lines run past the right edge now show a thin horizontal scrollbar at rest, not only while scrolling. Before, the only hint was the right-edge fade: the diff thumb was transparent until scrolled, and Shell and output bodies used WebKit's overlay scroller, which shows only while scrolling. A card cut to its eight-line preview now ends with the scrollbar in view; a cut diff no longer leaves it under the hidden last line.

Bug Fixes

  • SIDEWAYS_ATTRIBUTE (set by watchSideways) shows the at-rest thumb on Pierre's diff rows; Shell and output bodies carry SIDEWAYS_SCROLLER_ATTRIBUTE from their JSX so WebKit makes their scrollbar the thin one from the start.
  • Streaming measures only the changed block, once per frame, instead of re-walking the whole card per token; a removed block releases its shadow root's observers and listeners.
  • The scrollbar lane never shifts the card or transcript: call outputs carry it in padding, Shell bodies keep it while lines overflow or the command is running, and wrapped or settled fitting bodies have none.
  • A cut call output preview now shows eight whole lines plus the lane under them, instead of losing the lane out of the cut.
  • Adds browser tests in real Chromium and WebKit over the long-line gallery states, including running commands and cut previews.