Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion webviews/src/agent-session/acpmux/wheelGlide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ export function isWheelNotch(event: Pick<WheelEvent, "deltaY" | "deltaMode"> & {
/// 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 = () => {
Expand Down Expand Up @@ -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;
},
};
}
59 changes: 59 additions & 0 deletions webviews/test/browser/transcript-older-scroll.harness.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".acpmux-scroll");
const ask: Older["asks"][number] = { at: performance.now(), top: scroller?.scrollTop ?? -1 };
older.asks.push(ask);
return new Promise<void>((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 (
<VirtualTranscript
rows={turnView(all.slice(from), new Set())}
sessionId="session-older"
onToggleActivity={() => {}}
expanded={new Set()}
canLoadOlder={from > 0}
onLoadOlder={onLoadOlder}
/>
);
}
createRoot(root).render(<Transcript />);
12 changes: 12 additions & 0 deletions webviews/test/browser/transcript-older-scroll.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>transcript older-history scroll harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./transcript-older-scroll.harness.tsx"></script>
</body>
</html>
6 changes: 3 additions & 3 deletions webviews/test/reopened-chat-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<typeof progress>;
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)));
}
Expand Down
112 changes: 112 additions & 0 deletions webviews/test/transcript-older-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createServer>> | 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<HTMLElement>(".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<typeof place>;
for (let frame = 0; frame < frames; frame += 1) {
const now = (await page.evaluate(place)) as ReturnType<typeof place>;
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<typeof place>;
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();
});
}
});
46 changes: 46 additions & 0 deletions webviews/test/wheel-glide-motion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>();
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;
Expand All @@ -48,6 +67,7 @@ function fixture() {
};
return {
node,
cancelling: () => box.cancelling.size,
floorOffsets() {
round = Math.floor;
},
Expand Down Expand Up @@ -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();
}
});
Loading