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/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/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))); } 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(); + } +});