Agent chat: a trackpad scrolls up through older history without stalling

Merged
TE
7d ago
+258-6

Summary

Leo (2026-10-29): "scrolling up when it paginates and loads... didnt we previously have it pretty fast? why is it sometimes blocking me from scrolling up further?"

Two causes:

  1. The wheel glide held every wheel event for the page. Since #26114 (2026-10-27), installWheelGlide has kept a non-passive wheel listener on the transcript. While one is there, WebKit hands each wheel event to the page before it scrolls, so a trackpad's scroll waited on whatever the main thread was doing. The worst case is an older page of history folding and rendering as it lands, which matches "sometimes blocking me from scrolling up" and "previously pretty fast". A passive listener now watches the wheel. The cancelling listener stays installed only until the first precise (trackpad, Mos) event, and is added back on the next notch. A fresh pane's first notch still glides as before. The first notch after trackpad use scrolls natively, and the ones after it glide. No new timer or observer: the watcher is removed with the glide's other listener in remove() (unmount).
  2. Older pages were requested too late. The transcript asked for the next older page within two viewports of the first loaded row: at a fast flick that's a fraction of a second before the top. It now asks within six (OLDER_AHEAD_VIEWPORTS). Loaded rows stay virtualized, so the only cost is landing pages earlier. That cost is per page, not per token.

I first suspected the scroll-anchor base from #25113 and tried anchoring from the live offset and correcting with scrollBy. The new harness showed no difference between base and head for that change, so it isn't in this PR.

Performance

Same harness at base and head: webviews/test/transcript-older-scroll.test.ts (headless WebKit, 900×700, 3000-row synthetic chat opened on its last 120 rows, 30-row pages that land 400 ms after each ask, real wheel input one step a frame).

metricbeforeafterdeltaharness or run link
offset when each older page is asked for (40 px/frame)1382–1393 px (2 viewports)3641–4198 px (6 viewports)+2.9× leadbase, head
same, 100 px/frame1347–1394 px3641–4198 px+2.9× leadsame
time from an ask to reaching the top at 100 px/frame (6000 px/s)~0.23 s~0.70 s+0.47 sderived from the row above
frames at the top waiting for a page000same
wheel listeners that can cancel, after trackpad input1 (always)0−1wheel-glide-motion.test.ts
travel lost against the wheel, 100 px/frame × 3004580 px4098 pxwithin noisesame (the no-paging control loses ~10k px too: headless WebKit drops fast wheel delta)

Testing

Red on base, green on head (the commit before the fix holds only the tests):

  • wheel-glide-motion.test.ts › "precise input scrolls with no cancelling wheel listener; the next notch arms the glide again": base fails (Expected: 0, Received: 1), head passes.
  • transcript-older-scroll.test.ts (new browser test, WebKit): base fails on both speeds (first ask at 1382 / 1347 px, expected ≥ 2800), head passes.

Head also ran, all green: agent-pane-wheel-glide, transcript-parked-stream, transcript-fling, transcript-cached-rows-browser, transcript-older-history-at-top / once / retry, reopened-chat-history, transcript-scroll-restore, jump-scroll-animation, agent-pane-older-rows-fade, transcript-fold-anchor-browser, agent-pane-stream-jitter, agent-pane-scrollbar-shown (run). Command, from webviews/: cmux-ci test --setup playwright -- bun test test/<file>.

Observed / Not run

  • Observed: the red/green runs and the suite above; scripts/web-lint.sh passes.
  • Not run: the main-thread hold itself in WKWebView on macOS. Headless Linux WebKit doesn't show the scrolling thread waiting on a non-passive listener, so the browser test checks the listener state, not the wait. Not checked in a tagged app build or with a real trackpad.
  • Behavior change to #26114 (Leo's): after trackpad use, the first mouse notch scrolls natively; the next one glides.

Changelog

Fixed: Scrolling up the agent chat with a trackpad no longer stalls while older messages load

Proof

No visual change: the before/after is the scroll behavior, and the numbers above. Nothing to screenshot.

Agent session: agent session

🤖 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

Fixes trackpad scrolling up the agent chat stalling while older history pages load.

Two causes were addressed:

  • The wheel glide kept a cancelling wheel listener on the transcript, which made WebKit hold every wheel event until the page handled it. Older pages rendering during a scroll up could stop a trackpad mid-flick. The cancelling listener is now installed only while notched (mouse) input is active and is removed on the first precise (trackpad, Mos) event; a passive listener watches for the next notch.
  • Older history pages are requested six viewports from the first loaded row instead of two, so a page lands before a fast scroll reaches the top. Loaded rows stay virtualized, so the only cost is landing pages earlier.