From 9afad4341e8248675f114e5f337832293a54432a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 00:54:38 -0700 Subject: [PATCH 1/2] test(agent-chat): reproduce scrolling held by the wheel glide's cancelling listener 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?" Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0a7dc7e74a6aa0e0d03c --- .../transcript-older-scroll.harness.tsx | 59 +++++++++ .../test/browser/transcript-older-scroll.html | 12 ++ webviews/test/transcript-older-scroll.test.ts | 112 ++++++++++++++++++ webviews/test/wheel-glide-motion.test.ts | 46 +++++++ 4 files changed, 229 insertions(+) create mode 100644 webviews/test/browser/transcript-older-scroll.harness.tsx create mode 100644 webviews/test/browser/transcript-older-scroll.html create mode 100644 webviews/test/transcript-older-scroll.test.ts diff --git a/webviews/test/browser/transcript-older-scroll.harness.tsx b/webviews/test/browser/transcript-older-scroll.harness.tsx new file mode 100644 index 000000000000..e099ef78db7e --- /dev/null +++ b/webviews/test/browser/transcript-older-scroll.harness.tsx @@ -0,0 +1,59 @@ +// The chat's transcript with older history paging in as the reader scrolls up (App.tsx checkOlder), +// for transcript-older-scroll.test.ts: the chat opens on its tail, and each ask lands one page of +// older rows above the first after `window.__older.latency` ms, as a read from acpmux would. +import { useCallback, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { VirtualTranscript } from "../../src/agent-session/acpmux/App"; +import { syntheticRows } from "../../src/agent-session/acpmux/synthetic"; +import { turnView } from "../../src/agent-session/acpmux/conversation/turns"; +import "../../src/pages/shared/desktop.css"; +import "../../src/agent-session/shared/styles.css"; +import "../../src/agent-session/acpmux/styles.css"; +import "../../src/agent-session/acpmux/conversation/conversation.css"; + +const root = document.getElementById("root")!; +document.documentElement.style.height = document.body.style.height = "100%"; +document.body.style.margin = "0"; +Object.assign(root.style, { height: "100%", display: "flex", flexDirection: "column" }); + +const all = syntheticRows(3000); +const PAGE = 30; +type Older = { + latency: number; + /** Each ask: when it was made, the offset then, and when its page landed. */ + asks: { at: number; top: number; landed?: number }[]; + loaded: () => number; + total: number; +}; +const older: Older = { latency: 400, asks: [], loaded: () => 0, total: all.length }; +(window as unknown as { __older: Older }).__older = older; + +function Transcript() { + // `?all`: every row from the start and nothing to page, the control the paging is measured against. + const [from, setFrom] = useState(location.search.includes("all") ? 0 : all.length - 2 * PAGE); + older.loaded = () => all.length - from; + const onLoadOlder = useCallback(() => { + const scroller = document.querySelector(".acpmux-scroll"); + const ask: Older["asks"][number] = { at: performance.now(), top: scroller?.scrollTop ?? -1 }; + older.asks.push(ask); + return new Promise((resolve) => + // fixed-wait: the read's latency is the behavior under test, the time a page takes to land. + setTimeout(() => { + ask.landed = performance.now(); + setFrom((at) => Math.max(0, at - PAGE)); + resolve(); + }, older.latency), + ); + }, []); + return ( + {}} + expanded={new Set()} + canLoadOlder={from > 0} + onLoadOlder={onLoadOlder} + /> + ); +} +createRoot(root).render(); diff --git a/webviews/test/browser/transcript-older-scroll.html b/webviews/test/browser/transcript-older-scroll.html new file mode 100644 index 000000000000..260a7a502397 --- /dev/null +++ b/webviews/test/browser/transcript-older-scroll.html @@ -0,0 +1,12 @@ + + + + + + transcript older-history scroll harness + + +
+ + + diff --git a/webviews/test/transcript-older-scroll.test.ts b/webviews/test/transcript-older-scroll.test.ts new file mode 100644 index 000000000000..df16465d62bd --- /dev/null +++ b/webviews/test/transcript-older-scroll.test.ts @@ -0,0 +1,112 @@ +// Scrolling up a long chat asks for older history well before the top, so the view never stops +// there waiting for a page (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?"). +// The reader scrolls up with real wheel input, one step a frame, while each older page takes +// 400 ms to land (a read from acpmux). +// +// Real WebKit from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { webkit, type Browser, type Page } from "playwright"; +import { installPaintedFrames } from "./support/paintedFrames"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; + +await requireBrowserLane("transcript-older-scroll.test.ts", async () => { + setDefaultTimeout(120_000); + try { + await (await webkit.launch({ headless: true })).close(); + } catch { + console.warn("transcript-older-scroll: skipping (run `bunx playwright install webkit`)"); + describe.skip("transcript older scroll", () => test("webkit", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + let vite: Awaited> | undefined; + let browser: Browser | undefined; + beforeAll(async () => { + vite = await createServer({ + root: webviews, + configFile: path.join(webviews, "vite.config.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + await warmViteServer(vite, "/test/browser/transcript-older-scroll.html"); + browser = await webkit.launch({ headless: true }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + /// How far into the chat the view is: its offset less the height older pages added above the + /// first row it started on. A page that lands moves the offset and the content alike, so this moves + /// only with the reader's scroll (and with a correction that loses or adds to it). + const place = () => { + const scroller = document.querySelector(".acpmux-scroll")!; + const older = (window as unknown as { __older: { loaded(): number; total: number } }).__older; + return { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + more: older.loaded() < older.total, + }; + }; + + /// Scrolls up `frames` wheel steps of `step` px, one a frame, and measures how far the view went + /// into the chat against how far the wheel asked. WebKit applies a wheel step on its scrolling + /// thread and may report it to the page a frame later, so the travel is read once the scroll has + /// settled, never step by step. + const scrollUp = async (page: Page, step: number, frames: number) => { + let topFrames = 0; + let wheeled = 0; + await page.mouse.move(450, 350); + const start = (await page.evaluate(place)) as ReturnType; + for (let frame = 0; frame < frames; frame += 1) { + const now = (await page.evaluate(place)) as ReturnType; + if (now.more && now.scrollTop <= 1) topFrames += 1; + if (!now.more && now.scrollTop <= 1) break; + await page.mouse.wheel(0, -step); + wheeled += step; + await page.evaluate(() => window.cmuxPaintFrame()); + } + for (let frame = 0; frame < 20; frame += 1) await page.evaluate(() => window.cmuxPaintFrame()); + const end = (await page.evaluate(place)) as ReturnType; + const travel = start.scrollTop - end.scrollTop + (end.scrollHeight - start.scrollHeight); + return { wheeled, travel: Math.round(travel), lost: Math.round(wheeled - travel), topFrames }; + }; + + for (const step of [40, 100]) { + test(`a ${step} px-a-frame scroll up asks for older pages well before the top`, async () => { + const { port } = vite!.httpServer!.address() as { port: number }; + const page = await browser!.newPage({ viewport: { width: 900, height: 700 } }); + await installPaintedFrames(page); + await page.goto( + `http://127.0.0.1:${port}/test/browser/transcript-older-scroll.html${process.env.OLDER_SCROLL_ALL ? "?all" : ""}`, + ); + await page.waitForFunction(() => document.querySelectorAll(".acpmux-row").length > 5); + // Notches glide over several frames; this measures a precise trackpad's step-a-frame scroll. + await page.evaluate(() => (document.documentElement.dataset.wheelGlide = "off")); + for (let frame = 0; frame < 10; frame += 1) await page.evaluate(() => window.cmuxPaintFrame()); + const frames = 300; + const result = await scrollUp(page, step, frames); + const asks = await page.evaluate(() => { + const older = (window as unknown as { __older: { asks: { at: number; top: number; landed?: number }[] } }) + .__older; + return older.asks.map((ask) => ({ top: Math.round(ask.top), ms: Math.round((ask.landed ?? NaN) - ask.at) })); + }); + console.log( + `transcript-older-scroll ${step}px: ${JSON.stringify({ ...result, asks: asks.length, first: asks.slice(0, 4) })}`, + ); + if (!process.env.OLDER_SCROLL_ALL) expect(asks.length).toBeGreaterThan(1); + // Each page is asked for at least four viewports above the view (two left a flick a fraction + // of a second before the top), and the view never sat at the top waiting. The travel against + // the wheel is logged: headless WebKit drops some of a fast wheel's distance with no paging + // at all (OLDER_SCROLL_ALL), so it is not asserted. + expect(Math.min(...asks.map((ask) => ask.top))).toBeGreaterThanOrEqual(4 * 700); + expect(result.topFrames).toBe(0); + await page.close(); + }); + } +}); diff --git a/webviews/test/wheel-glide-motion.test.ts b/webviews/test/wheel-glide-motion.test.ts index 5c6ebc638ba5..eb1b2910de8d 100644 --- a/webviews/test/wheel-glide-motion.test.ts +++ b/webviews/test/wheel-glide-motion.test.ts @@ -23,6 +23,25 @@ function fixture() { const doc = Object.assign(new EventTarget(), { defaultView: page, hidden: false, documentElement: { dataset: {} } }); let round = Math.round; class Scroller extends EventTarget { + /// The wheel listeners that may cancel the scroll: WebKit sends each wheel to the page first + /// while there is one. + cancelling = new Set(); + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ) { + if (type === "wheel" && !(typeof options === "object" && options.passive)) this.cancelling.add(listener); + super.addEventListener(type, listener, options); + } + override removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: EventListenerOptions | boolean, + ) { + if (type === "wheel") this.cancelling.delete(listener); + super.removeEventListener(type, listener, options); + } ownerDocument = doc; clientHeight = 500; scrollHeight = 2500; @@ -48,6 +67,7 @@ function fixture() { }; return { node, + cancelling: () => box.cancelling.size, floorOffsets() { round = Math.floor; }, @@ -230,3 +250,29 @@ test.each( f.restore(); } }); + +// A wheel listener that may cancel makes WebKit hold every wheel event for the page before it +// scrolls, so a trackpad's scroll stopped while the page was busy, as when an older page of history +// lands (Leo 2026-10-29: "why is it sometimes blocking me from scrolling up further?"). +test("precise input scrolls with no cancelling wheel listener; the next notch arms the glide again", () => { + const f = fixture(); + try { + // A fresh pane's first notch glides. + expect(f.wheel(-5)).toBe(true); + f.finish(); + // A trackpad: no wheel listener may cancel once it is seen, so WebKit scrolls on its own. + expect(f.wheel(-40, { deltaMode: 0, wheelDeltaY: 120 })).toBe(false); + expect(f.cancelling()).toBe(0); + expect(f.wheel(-12, { deltaMode: 0, wheelDeltaY: 36 })).toBe(false); + expect(f.cancelling()).toBe(0); + // A mouse again: its first notch scrolls natively and arms the glide; the next one glides. + expect(f.wheel(-5)).toBe(false); + expect(f.cancelling()).toBe(1); + expect(f.wheel(-5)).toBe(true); + f.finish(); + f.glide.remove(); + expect(f.cancelling()).toBe(0); + } finally { + f.restore(); + } +}); From ac90c11d70197881fa384c4ebc8a172c1c16564f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 00:54:38 -0700 Subject: [PATCH 2/2] fix(agent-chat): a trackpad scrolls the transcript without waiting for the page Since #26114 the wheel glide kept a wheel listener that may cancel on the transcript, and WebKit sends every wheel event to the page before it scrolls while one is there: a trackpad's scroll up waited for whatever the page was doing, most of all an older page of history folding and rendering, and stopped. A passive listener now watches the wheel; the cancelling one stays only until the first precise event and comes back with the next notch (which scrolls natively). Older pages are asked for six viewports from the first row instead of two, so a page lands before a flick reaches the top. 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?" Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0a7dc7e74a6aa0e0d03c --- webviews/src/agent-session/acpmux/App.tsx | 8 +++++-- .../src/agent-session/acpmux/wheelGlide.ts | 21 ++++++++++++++++++- webviews/test/reopened-chat-history.test.ts | 6 +++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index d6b32b7e9568..0d0a57bb3f9c 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1906,6 +1906,10 @@ const OPEN_AT_PAGE_MS = 1500; const OPEN_AT_SEEK_MS = 8000; /// How soon the transcript asks again for an older page after an ask that brought nothing. const OLDER_RECHECK_MS = 500; +/// How near the first loaded row, in viewports, the view asks for the next older page: far enough +/// ahead that a page read from acpmux lands before a fast scroll up reaches the top (two viewports +/// was a fraction of a second of a flick). +const OLDER_AHEAD_VIEWPORTS = 6; /// Scrolls `scroller` so `range` sits a third of the way down it, when it is not already in view /// above the `inset` px at its foot that the bottom stack covers. function scrollRangeIn(scroller: HTMLElement, range: Range, inset = 0) { @@ -3943,7 +3947,7 @@ export function VirtualTranscript({ // layout effect scanned every commit, including scroll/follow state commits that cannot change // these attributes. `pointAt` still calls the callback directly for pointer-driven updates. useLayoutEffect(markTurns, [markTurns, rows, range.first, range.last]); - // Older history pages in before the reader reaches it: whenever the view is within two viewports + // Older history pages in before the reader reaches it: whenever the view is within six viewports // of the first loaded row, including a tail too short to fill them. The anchoring above keeps the // reader's row in place as each page lands. // A read that drew no row above the first (records that draw nothing) moves historyFrom: ask again. @@ -3966,7 +3970,7 @@ export function VirtualTranscript({ olderLoadRequested.current = undefined; return; } - if (node.scrollTop >= node.clientHeight * 2) { + if (node.scrollTop >= node.clientHeight * OLDER_AHEAD_VIEWPORTS) { // Allow the next visit to the threshold to request the next page, even when the first row // did not change while the reader was away from the top. olderLoadRequested.current = undefined; diff --git a/webviews/src/agent-session/acpmux/wheelGlide.ts b/webviews/src/agent-session/acpmux/wheelGlide.ts index 1fb3c9dc4c01..d7627afe56ca 100644 --- a/webviews/src/agent-session/acpmux/wheelGlide.ts +++ b/webviews/src/agent-session/acpmux/wheelGlide.ts @@ -23,7 +23,14 @@ export function isWheelNotch(event: Pick & { /// Owns only notch input, on the existing single scroll clock. No anchor or follow-state writes. /// A replacement jump, native wheel, other input, hidden page/pane, session switch or removal ends /// the glide; the shared runner cancels its frame and listeners. The installer removes its wheel -/// listener on unmount. Completed notches leave no timer or listener behind. +/// listeners on unmount. Completed notches leave no timer or listener behind. +/// +/// Only a notched wheel needs to cancel the browser's scroll, and a wheel listener that may cancel +/// makes WebKit send every wheel event to the page before it scrolls: a trackpad's scroll then +/// waited for whatever the page was doing (an older page of history landing), and stopped (Leo +/// 2026-10-29: "why is it sometimes blocking me from scrolling up further?"). So a passive listener +/// watches the wheel, and the cancelling one is there until the first precise (trackpad, Mos) event +/// and again from the next notch, which scrolls natively. export function installWheelGlide(node: HTMLElement): { stop(): void; remove(): void } { let ride: { remaining(): number } | undefined; const finished = () => { @@ -74,12 +81,24 @@ export function installWheelGlide(node: HTMLElement): { stop(): void; remove(): event.preventDefault(); ride = animateScrollBy(node, distance, duration, [0.22, 1, 0.36, 1], wanted >= max, finished); }; + let armed = true; node.addEventListener("wheel", onWheel, { passive: false }); + const watch = (event: WheelEvent) => { + if (!event.deltaY || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; + const notch = isWheelNotch(event); + if (notch === armed) return; + armed = notch; + if (notch) node.addEventListener("wheel", onWheel, { passive: false }); + else node.removeEventListener("wheel", onWheel); + }; + node.addEventListener("wheel", watch, { passive: true }); return { stop, remove: () => { stop(); + node.removeEventListener("wheel", watch); node.removeEventListener("wheel", onWheel); + armed = false; }, }; } diff --git a/webviews/test/reopened-chat-history.test.ts b/webviews/test/reopened-chat-history.test.ts index 2472b4c703c3..88dae91f4737 100644 --- a/webviews/test/reopened-chat-history.test.ts +++ b/webviews/test/reopened-chat-history.test.ts @@ -355,8 +355,8 @@ await requireBrowserLane("reopened-chat-history.test.ts", async () => { /// happened and not by how long ago. Either of two things is enough: /// /// - acpmux has served the chat's oldest record, so nothing older is left to read; or - /// - the transcript holds two viewports of history, which is where the older-history loader - /// stops asking (`node.scrollTop >= node.clientHeight * 2`, App.tsx). A view with that much + /// - the transcript holds six viewports of history, which is where the older-history loader + /// stops asking (`node.scrollTop >= node.clientHeight * OLDER_AHEAD_VIEWPORTS`, App.tsx). A view with that much /// below it has been placed at its latest row and is pinned there as each page lands /// (`didOpenAtLatest`, `settleFollowBottom`), so records arriving above the first row move the /// rows above the reader and not the last one off the composer. @@ -375,7 +375,7 @@ await requireBrowserLane("reopened-chat-history.test.ts", async () => { const until = Date.now() + 25_000 + 20 * LATENCY_MS; for (let pass = 0; pass < 600 && Date.now() < until; pass += 1) { const now = (await page.evaluate(`(${progress.toString()})()`)) as ReturnType; - if (now.rows > 0 && (servedOldest || now.scrollable > 2 * now.view)) return true; + if (now.rows > 0 && (servedOldest || now.scrollable > 6 * now.view)) return true; // The next look comes a frame later, once the page has drawn what it has. await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); }