Agent chat: a mouse wheel's notch glides the transcript instead of jumping

Closed
TE
9d ago
+1322-13

Leo-ask: 102e9a090f
Leo-ask: 6e88488b1c (build half)

Leo: "smoother scrolling in webkit: i mean with a mouse instead of just trackpad. i have mos and lawrence doesn't, tho we'd presumably be doing a bit of our handrolled mos-ish thing"

Context, from his message just before it: "...the former [chromium] has smoother scroll btu the latter [webkit] has glass... but we should be able to do smoother scroll on webview too right?"

What changed

In the agent chat transcript, a notched mouse wheel now glides instead of jumping. Each notch eases the view toward an accumulated target over the motion scale's standard step, 180 ms (MOTION_MS.standard, ease-out cubic). A notch that comes in mid-glide adds its distance to the target. Trackpad and momentum scrolling are untouched, so the engine still handles them.

  • webviews/src/agent-session/acpmux/wheelGlide.ts: isWheelNotch and installWheelGlide. No engine says which device sent a wheel event, so the notch test reads what each engine leaves behind (WebKit's WebEventFactory.mm / WheelEvent.cpp):
    • deltaMode lines or pages mean a wheel.
    • Chromium: a tick counts as 120 in the legacy wheelDeltaY, whatever its pixels. A precise scroll's wheelDeltaY is 3 times its pixels.
    • WebKit (macOS): every event has wheelDeltaY = 3 times its pixels, so the pixels decide. A notch scrolls its accelerated lines times 40, and those lines are 16.16 fixed point (a slow notch is 4.000244140625 px). A trackpad (hasPreciseScrollingDeltas) scrolls whole points.
    • When the test is unsure, the event goes to the engine, never to the glide.
  • App.tsx: the transcript's scroller installs the glide. Jump to latest stops a glide in flight.
  • wheelPassThrough.ts (forwardWheel, after #24024): the copy sent from over Jump to latest, the stack gutters or the minimap's hover card is now cancelable and carries wheelDeltaY. A notch there glides as well. A trackpad scroll there still scrolls at once, as before.
  • Debug Settings: Smooth mouse wheel in the transcript (agentPane.debug.wheelGlide, on by default) in AgentPaneTunables.swift, following the streamWords pattern. Off sets data-wheel-glide="off" on the page's <html>, and the wheel scrolls as WebKit does. New agent tabs read it. Reduce Motion and the app's motion off (data-agent-motion="off") also leave the notch to the engine.
  • Interplay: the wheel listener on the scroller that marks the reader's own scroll (readerAt) still runs for every notch, and the glide moves the view through ordinary scroll events. So a wheel up leaves follow mode as it does today, and stick-to-bottom never fights it. A row that scrolls on its own (wheelTarget) still takes the wheel first. At the top, the wheel still asks for older history.
  • Overlap: I checked #22341, #23243, #23062 and #23650; no existing wheel glide was found in the webviews. #23807 (merged) removed the 180 ms growth glide in the follow path. This PR doesn't touch that path.

Timers and cleanup

One requestAnimationFrame loop per transcript, running only while a glide is in flight.

  • Stop conditions:
    • the glide covers its distance (180 ms after the last notch);
    • the view reaches the top or bottom end;
    • any wheel event the glide doesn't take arrives: a trackpad's or momentum's, a modified (shift, alt, meta, ctrl) or sideways one, one over a part that scrolls on its own (code, a long tool output), or one while Reduce Motion or the Debug toggle is off;
    • the reader presses, touches or types anywhere in the page (a drag on the scrollbar, a keyboard scroll, a click on Jump to latest or the minimap; a window capture listener does this, added only while a glide runs);
    • Jump to latest is used;
    • the transcript switches to another chat (VirtualTranscript is kept across chats, so the switch stops the glide);
    • the pane is hidden mid-glide (the scroller has no height).
  • Cleanup: unmount calls remove(), which cancels the frame, removes the window listeners and removes the scroller's wheel and scroll listeners.
  • No new work per token or per row.

Tests

  • webviews/test/agent-pane-wheel-glide.test.ts, run in Chromium and Playwright WebKit. Playwright's WebKit wheel is always a precise (trackpad) scroll, so the WebKit notch is the event macOS WebKit dispatches for a mouse wheel, built in the page: 100.006103515625 px, wheelDeltaY 300. It checks:
    • a notch, and Chromium's own wheel tick, are taken by the page;
    • the view covers the whole distance through at least two positions on the way, taking 100 ms or more from its first move to rest (time-based, so a loaded runner's skipped frames don't matter);
    • notches during a glide add up;
    • a trackpad scroll is not taken (defaultPrevented false) and Chromium applies it in one frame;
    • Reduce Motion and data-wheel-glide="off" leave the notch to the engine;
    • a notch over Jump to latest reaches the transcript with its shift, alt and meta keys.
  • transcript-wheel-glide.test.ts (Chromium and WebKit, the transcript harness), for the #24327 review:
    • a switch to another chat mid-glide leaves the new chat at its end on every frame;
    • a notch over a part that scrolls on its own ends the glide where it is: the content holds from the next frame. Measured by where the content is, not scrollTop, since rows above that measure move the offset without moving anything on screen.
  • wheelGlide.test.ts also drives installWheelGlide on a stand-in scroller and frame clock. It checks each stop path frame by frame (a nested part, a trackpad, modified and sideways wheels, stop(), a hidden pane), and that remove() with a frame pending cancels it and takes every listener off.
  • wheelGlide.test.ts: unit cases for isWheelNotch, using the values each engine dispatches.
  • agent-pane-wheel-pass-through.test.ts (existing) runs alongside it, unchanged.

Before and after (same harness, huge-blue, Linux headless; one notch of 100 px)

InputBase: max jump in one frameBase: frames movingHead: max jump in one frameHead: frames movingHead: first move to rest
Chromium wheel tick (page.mouse.wheel(0, -100))100 px123 px8-10150 ms
WebKit macOS notch event (100.006 px)90-95 px333-58 px4-6185-204 ms
Chromium trackpad (40 px, precise)40 px140 px1unchanged
WebKit trackpad (120 px, precise)engine's own3engine's own4unchanged, not taken

Linux WPE animates a wheel step over 2-3 frames by itself (see the #23062 note). macOS WebKit applies it in one frame, which is the jump Leo sees. On WPE the base red is therefore that the page doesn't take the notch (defaultPrevented false); the max jump of 95 of 100 px also shows it. WebKit's head frame counts vary because the loaded host skips frames; the glide is time-based, so it still takes its 180 ms (200 ms when these numbers were taken, before 4c55c45).

Same file on huge-blue: base (test-only commit) fails the 3 Chromium glide cases and the 2 WebKit glide cases; trackpad and toggle cases pass. Head passes all 9.

Review fixes (head 75ab7f8620dff6c1dda5c0dff0b3774e3ac6f189; test-only commit 9c1cdd7a46f0eeaf39a7dba30cfc377bf2d01a8c):

  • huge-blue, old code with the new tests: red. Chromium: the chat switch and the nested part. WebKit: the chat switch. Unit: the nested part, the trackpad, modified and sideways wheels, and the hidden pane. Both engines: the modifiers on the Jump to latest copy.
  • huge-blue, head: wheelGlide.test.ts 12/12, transcript-wheel-glide 4/4 (3 runs in a row), agent-pane-wheel-glide 11/11, agent-pane-wheel-pass-through 6/6, thread-minimap-feel 4/4.
  • WebKit's nested case passes on the old code too: WebKit scrolls for a dispatched wheel event, so the test scrolls the part partway first and holds the content still either way. The Chromium case and the unit test cover that path.
  • Fleet red at the test-only commit 9c1cdd7a46f0eeaf39a7dba30cfc377bf2d01a8c: step 2e4ac98a251ada15c8e99e0c, exit 1. Every new review case fails in both engines, and so do the new unit cases; the earlier cases pass.
  • Fleet green at 75ab7f8620dff6c1dda5c0dff0b3774e3ac6f189: step e0c7ae052a63269942457d4d, 4 files, 0 failed. Compile-check --app passed (job 2ff932550df981ca3a084859).

Current head dc000a7549b10202dc44442f1ab0cbe8b4fb065e (75ab7f8620d plus a merge of feat-cmux-next, which brings in #24377's fix for the PaneMotionBench compile red on base):

  • Fleet green: step 8d9135ebafd227de8f754428 (aws-m4pro-6). It ran wheelGlide.test.ts, agent-pane-wheel-glide, transcript-wheel-glide and thread-minimap-feel: 4 files, 0 failed, exit 0.
  • Compile: cmux-ci compile-check --ref dc000a7549b10202dc44442f1ab0cbe8b4fb065e --app passed (job e0b527f05b68a4d4f87ed8df).
  • Swift: AgentPaneThoughtTunablesTests run alone on the fleet, step 17934d5ed345480051f901f1 (aws-m4pro-2): 9/9 passed, including wheelGlideIsOnByDefaultAndOffSetsItsAttribute().

Review round 2 (head 683d6d8f5e32d49299cbc7334e61ad4b39159c22; test-only commit faca193c63da1d3262e518085cd2bfa3956216f4):

  • 4c55c45 ran the glide for MOTION_MS.standard (180 ms) instead of a literal 200 ms. motion-scale.test refused the 200.
  • A page hidden mid-glide ends it. WebKit holds a hidden page's frames, so the old glide finished in one jump when the page came back. A visibilitychange listener now stops the glide when document.hidden. It is added only while a glide runs and removed in stop(), so remove() takes it off too. The frame step also checks document.hidden. It is allowlisted in check-switch-fetches.py because it reads no data.
  • A notch that moves as much sideways as down goes to the engine, the same deltaY <= deltaX rule wheelPassThrough uses.
  • Fleet red at faca193: step e6fd68b2d23055e80f95cbee, exit 1. These fail: the Chromium hidden-page case, the unit hidden-page case, the diagonal case and the sideways list. Earlier cases pass.
  • Fleet green at 683d6d8: step cb8ebc356ad2ed14dc9e9d0a. It ran wheelGlide.test, motion-scale, agent-pane-wheel-glide, thread-minimap-feel and transcript-wheel-glide: 5 files, 0 failed.
  • The fleet mini skipped WebKit in transcript-wheel-glide ("skipping webkit"), so there Chromium and the unit test cover the hidden-page case.
  • At 4c55c45 (the last Swift change): compile-check --app passed (job e6b8c0e48d6d06f70d8a1350), and AgentPaneThoughtTunablesTests passed 9/9 (step 9163689c79abb133800284c7). Round 2 touches no Swift.
  • Base reds: agent-pane-older-rows-fade fails the same way at the base merge a687a49 (step 15ced7124fd88e55d6dc53dd). agent-pane-wheel-pass-through times out with no output at both base a687a49 (step 1e1b85dd1c7bc4da2824ac59) and head 4c55c45 (step 5eface195dd067e8a0e74f76).

Review round 3 (head 3a632eb2194f36e3a53cf5907770fbabc1c44a90; test-only commit d8c9bdc4107c801f200c659dbb01fcc12480f930; base merged at 53173b229a8):

  • A glide to the bottom now follows a streaming reply. Before, a notch that sent the view to the bottom while a reply streamed was clamped once to the bottom of that moment. If the reply grew more than the 4 px follow deadband during the glide, it landed short, follow turned off and Jump to latest showed. A glide whose wheel asked for the bottom now extends to the current bottom each frame. That is one scrollHeight read the step already made, so no O(rows) work per frame. A glide short of the bottom keeps its own distance.
  • A modified wheel (shift, option, command) over Jump to latest or a stack gutter scrolls nothing now. Its copy is cancelled before dispatch, so the transcript's listeners still hear it (a glide in flight ends) but no scroll happens. Cancelling matters because WebKit scrolls for a dispatched wheel event: without it the copy still moved the transcript 100 px.
  • The glide check in agent-pane-wheel-glide now takes 2 or more frames with no frame covering more than 80% of the distance. A loaded mini painted only 2 frames of the 180 ms glide; the engine's own step is still one frame for all of it.
  • vp lint warning in the unit stand-in (no-useless-spread) fixed. That warning reddened fast gates at 683d6d8.
  • Fleet red at d8c9bdc on cmux10 (WebKit launches there): step 4708f1ec69802612a6389718, exit 1. These fail:
    • WebKit: the streaming-reply case and the modified-notch case;
    • Chromium: the modified-notch case;
    • unit: the bottom-growth case.
      The Chromium streaming case passes on the old code too, so in Chromium only the unit case shows this bug.
  • Fleet green at 3a632eb:
    • cmux10: step e0da532abb83ec0e14b31fcf, 3 files, 0 failed. WebKit cases ran by name on this worker at d8c9bdc and a4630d6; passing files print no per-test lines.
    • aws: step cb64cff8fdc861b3c98a6edb, motion-scale, wheelGlide.test, transcript-wheel-glide, agent-pane-wheel-glide, thread-minimap-feel, transcript-follow: 6 files, 0 failed.
  • Compile-check --app at a4630d6 (after the base merge) passed, job 602a4b41be4b577636c519e8. Round 3 touches no Swift.

Earlier head b6ae0c2b12d5cf847a4c580b3c5535930d995950:

  • huge-blue: agent-pane-wheel-glide.test.ts 9/9, agent-pane-wheel-pass-through.test.ts 6/6, thread-minimap-feel.test.ts 4/4, wheelGlide.test.ts 6/6. These ran with each file's browsers launched once; huge-blue hangs on the files' launch-then-relaunch probe even on base.
  • Fleet green: step 1d1d6dbb4af1f938768d6383 (aws-m4pro-9), running agent-pane-wheel-glide, wheelGlide.test.ts and thread-minimap-feel. 3 files, 0 failed, exit 0.
  • Fleet red: step cc051e132c65db3d275a5f35 (aws-m4pro-5) at the test-only commit bb3f5030247. The Chromium notch, notches-add-up and wheel-tick cases fail (prevented false, one 100 px jump); the trackpad and toggle cases pass.
  • Compile: cmux-ci compile-check --ref b6ae0c2b12d5cf847a4c580b3c5535930d995950 --app passed (fleet job 700e8b30aef37319af11a2db).

The last commit fixes a red this PR caused in thread-minimap-feel ("the band of lit rows follows the scroll on the scroll's own frame": 29-34 wrong frames). A scrollTop write in a frame callback fires its scroll event only in the next frame, so the minimap's lit band and the mounted rows painted one frame behind each glide step. Each step now dispatches its scroll event at once, and the browser's later event for the same offset is dropped. Checked on huge-blue: without the fix 34 wrong frames, with it 4/4 pass.

No capture: the only native change is one Debug Settings toggle row generated from its Tunable descriptor. What changes for the user is wheel motion in the webview, which the Chromium and WebKit frame test above measures.

Changelog

  • Changed: In the agent chat, a mouse wheel now scrolls the transcript smoothly instead of in steps.

Agent session: Air-Blue

šŸ¤– Generated with Claude Code