Skip to content

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

Merged
teamleaderleo merged 2 commits into
feat-cmux-nextfrom
scroll-page-prefetch
Oct 29, 2026
Merged

teamleaderleo merged 2 commits into
feat-cmux-nextfrom
scroll-page-prefetch

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

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 fix(agent-chat): glide mouse wheel notches on the shared scroll clock #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).

metric before after delta harness 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× lead base, head
same, 100 px/frame 1347–1394 px 3641–4198 px +2.9× lead same
time from an ask to reaching the top at 100 px/frame (6000 px/s) ~0.23 s ~0.70 s +0.47 s derived from the row above
frames at the top waiting for a page 0 0 0 same
wheel listeners that can cancel, after trackpad input 1 (always) 0 −1 wheel-glide-motion.test.ts
travel lost against the wheel, 100 px/frame × 300 4580 px 4098 px within noise same (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 fix(agent-chat): glide mouse wheel notches on the shared scroll clock #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.

Written for commit ac90c11. Summary will update on new commits.

View guided diff Turn on auto-fix

teamleaderleo and others added 2 commits October 29, 2026 00:55
…lling 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 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0a7dc7e74a6aa0e0d03c
…r 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 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0a7dc7e74a6aa0e0d03c
@teamleaderleo teamleaderleo added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 29, 2026
@cursor

cursor Bot commented Oct 29, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review ac90c11: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr27311 -f ref=scroll-page-prefetch -f platform=macos -f backend_mode=local

Unclear: web / react-apps-check failed: no known signature; failed step: Check webview test shards.

CI failed on ac90c11d70 (run 40839439946 attempt 3): 2 code, 1 unknown.

Job Verdict Why
web / react-apps-base-check (1) code a web test failed
web / react-apps-base-check (2) code a web test failed
web / react-apps-check unknown no known signature; failed step: Check webview test shards

Failures:

  • seen on other PRs agent-pane-pending-steer.test.tsx Send now on a waiting steer asks queue_edit send_now for its promptId, and goes once it is read: failed (job)
  • seen on other PRs clickable-cursor.test.ts no stylesheet sets the arrow or auto on a clickable element: failed (job)
  • seen on other PRs motion-scale.test.ts motion scale > no webview CSS, TS or TSX animates off the scale: failed (job)
  • seen on other PRs running-strip.test.ts hover fills the whole line to the tray's edges, its stop button inside, with the hand: failed (job)
  • seen on other PRs chiefLook.test.tsx search renders Bubble Style in the Look card: failed (job)
  • seen on other PRs rowSummary.test.tsx no description line on any Settings page is over 60 characters, in any language: failed (job)
  • seen on other PRs rowSummary.test.tsx every schema summary fits 60 characters in every language: failed (job)
Matched log lines
web / react-apps-base-check (1): (fail) Send now on a waiting steer asks queue_edit send_now for its promptId, and goes once it is read [72.65ms]
web / react-apps-base-check (2): (fail) search renders Bubble Style in the Look card [3803.69ms]

Not re-run automatically: web / react-apps-base-check (1), web / react-apps-base-check (2), web / react-apps-check are not machine failures.

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on 4bc7428 to compare; do not merge until green; see lane-rules
red: web / Web status; base red: the same failure on the base 4bc7428; does not block
red: web / react-apps-base-check (1); no finished base run on 4bc7428 to compare; do not merge until green; see lane-rules
red: web / react-apps-base-check (2); no finished base run on 4bc7428 to compare; do not merge until green; see lane-rules
red: web / react-apps-check; base red: the same failure on the base 4bc7428; does not block

Gallery before/after for ac90c11: 16 states rendered, 0 images differ, 914 more touched states not rendered. Images are in the run's artifact, not on this public PR.

gh run download 40839439906 -R manaflow-ai/cmux -n gallery-advisory-27311-ac90c11d70197881fa384c4ebc8a172c1c16564f (then read gallery-review.json)

States
State Theme Status Diff Picked by
agent-pane.chat-width/standard Apple System Colors Light unchanged 0% own App.tsx
agent-pane.chat-width/standard Apple System Colors unchanged 0% own App.tsx
agent-pane.chat-width/wide Apple System Colors Light unchanged 0% own App.tsx
agent-pane.chat-width/wide Apple System Colors unchanged 0% own App.tsx
agent-pane.chat-width/wide-blocks Apple System Colors Light unchanged 0% own App.tsx
agent-pane.chat-width/wide-blocks Apple System Colors unchanged 0% own App.tsx
agent-pane.composer-edge/short-edge Apple System Colors Light unchanged 0% own App.tsx
agent-pane.composer-edge/short-edge Apple System Colors unchanged 0% own App.tsx
agent-pane.composer-edge/wide-edge Apple System Colors Light unchanged 0% own App.tsx
agent-pane.composer-edge/wide-edge Apple System Colors unchanged 0% own App.tsx
agent-pane.composer-edge/no-edge Apple System Colors Light unchanged 0% own App.tsx
agent-pane.composer-edge/no-edge Apple System Colors unchanged 0% own App.tsx
agent-pane.composer-floats/scrolled-to-end Apple System Colors Light unchanged 0% own App.tsx
agent-pane.composer-floats/scrolled-to-end Apple System Colors unchanged 0% own App.tsx
agent-pane.composer-floats/composer-grown Apple System Colors Light unchanged 0% own App.tsx
agent-pane.composer-floats/composer-grown Apple System Colors unchanged 0% own App.tsx
agent-pane.continuation-spacing/shipped Apple System Colors Light unchanged 0% own App.tsx
agent-pane.continuation-spacing/shipped Apple System Colors unchanged 0% own App.tsx
agent-pane.continuation-spacing/two-thirds Apple System Colors Light unchanged 0% own App.tsx
agent-pane.continuation-spacing/two-thirds Apple System Colors unchanged 0% own App.tsx
agent-pane.continuation-spacing/half-tucked Apple System Colors Light unchanged 0% own App.tsx
agent-pane.continuation-spacing/half-tucked Apple System Colors unchanged 0% own App.tsx
agent-pane.continuation-spacing/before Apple System Colors Light unchanged 0% own App.tsx
agent-pane.continuation-spacing/before Apple System Colors unchanged 0% own App.tsx
agent-pane.copy-controls/output Apple System Colors Light unchanged 0% own App.tsx
agent-pane.copy-controls/output Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/new-chat Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/new-chat Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/after-turn Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/after-turn Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/working Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/working Apple System Colors unchanged 0% own App.tsx

Pick other states with a Gallery: entry.id, entry.id/variant line in the PR body.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · session

@blacksmith-sh

blacksmith-sh Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Found 19 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmpr8w_t_jk/summary-fail.json (live):/summary-fail.json (live): View Logs
/tmp/tmpys354jbl/summary-missing.json (live):/summary-missing.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs
src/pages/settings/chiefLook.test.tsx/chiefLook.test.tsx View Logs
src/pages/settings/chiefLook.test.tsx/chiefLook.test.tsx View Logs
src/pages/settings/rowSummary.test.tsx/rowSummary.test.tsx View Logs
src/pages/settings/rowSummary.test.tsx/rowSummary.test.tsx View Logs

...and 9 more test failures. View all on Blacksmith: run 40839439946, run 40839439954

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Bead cx-0a0cvv created for this PR by the cmux coordinator.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex gpt-6-astra high fast via review-router, read-only): SHIP at ac90c11

I’ll inspect the full diff and the focused context files, then trace the wheel listener state machine and older-history request flow before deciding whether any correctness issue warrants BLOCK.
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "ls -la && printf '\nDIFF\n' && sed -n '1,260p' pr.diff" in /tmp/review-27311-ac90
succeeded in 0ms:
total 64
drwxr-xr-x 3 cmux cmux 4096 Oct 29 08:29 .
drwxrwxrwt 357 nobody nogroup 28672 Oct 29 08:29 ..
drwxr-xr-x 3 cmux cmux 4096 Oct 29 08:29 files
-rw-r--r-- 1 cmux cmux 19153 Oct 29 08:29 pr.diff
-rw-r--r-- 1 cmux cmux 1585 Oct 29 08:29 prompt.txt

DIFF
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<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 = () => {
    @@ -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 (
  • <VirtualTranscript
  •  rows={turnView(all.slice(from), new Set())}
    
  •  sessionId="session-older"
    
  •  onToggleActivity={() => {}}
    
  •  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 @@
    +<!doctype html>
    +
  • <title>transcript older-history scroll harness</title>
  • <script type="module" src="./transcript-older-scroll.harness.tsx"></script>

+
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<ReturnType> | 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<typeof place>;
    
  •  if (now.more && now.scrollTop <= 1) topFrames += 1;
    
  •  if (!now.more && now.scrollTop <= 1) break;
    

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "sed -n '261,620p' pr.diff && printf '\nFILES\n' && find files -maxdepth 2 -type f -printf '%p\n'" in /tmp/review-27311-ac90
succeeded in 0ms:

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

FILES
files/App.tsx.older-history-excerpt.txt

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "find files -type f -printf '%p\n' && printf '\nEXCERPT\n' && cat files/App.tsx.older-history-excerpt.txt" in /tmp/review-27311-ac90
succeeded in 0ms:
files/App.tsx.older-history-excerpt.txt
files/webviews/src/agent-session/acpmux/wheelPassThrough.ts
files/webviews/src/agent-session/acpmux/wheelGlide.ts
files/webviews/src/agent-session/acpmux/App.tsx
files/webviews/src/agent-session/acpmux/threadMinimap/scrollMotion.ts
files/webviews/test/transcript-older-scroll.test.ts
files/webviews/test/browser/transcript-older-scroll.harness.tsx
files/webviews/test/browser/transcript-older-scroll.html
files/webviews/test/agent-pane-wheel-glide.test.ts
files/webviews/test/wheel-glide-motion.test.ts
files/webviews/test/reopened-chat-history.test.ts

EXCERPT
const markTurns = useCallback(() => {
for (const node of thread.current?.querySelectorAll(".acpmux-row") ?? []) {
node.toggleAttribute("data-retryable", node.dataset.rowId === retryPromptId);
node.toggleAttribute("data-turn-revealed", node.dataset.rowId === pointedTurn.current);
}
}, [retryPromptId]);
// Mark only when the mounted rows, their range, or the last turn changes. The old no-deps
// 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 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.
const olderLoadKey = ${sessionKey}\u0000${rows[0]?.id ?? ""}\u0000${historyFrom ?? ""};
const olderLoadRequested = useRef<string | undefined>(undefined);
/// An older page asked for has not settled yet; and the check that follows one that brought nothing.
const olderAsking = useRef(false);
const olderRecheck = useRef<ReturnType>(undefined);
/// The latest ask: only its settling re-arms the check, so a late answer to an earlier one (a
/// chat switched away from) cannot cancel the recheck of the ask that followed it.
const olderAsk = useRef(0);
// The check reads the scroller's live offset, and runs on every scroll event as well as after
// each commit: a page that lands moves the view down (the anchoring above) and the reader's
// scroll back to the top could land in the same frame, so the rendered offset never changed
// and a check that ran only on a change of it never asked for the next page (the scroll to the
// first message stalled at the top after a few pages, perf100).
const checkOlder = () => {
const node = ref.current;
if (!canLoadOlder || !onLoadOlder || !node || node.clientHeight === 0) {
olderLoadRequested.current = undefined;
return;
}
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;
return;
}
if (olderLoadRequested.current === olderLoadKey) return;
const asked = olderLoadKey;
olderLoadRequested.current = asked;
olderAsking.current = true;
const ask = ++olderAsk.current;
// A page that lands changes the key in the commit it brings, and the check after it asks for
// the next. An ask that brought nothing (a read that failed, or one the client dropped) leaves
// the key as it was: ask once more a moment later, as long as the view is still near the top.
// At the very top no scroll event comes to ask for it, and a check on every render would ask
// on every streamed chunk (Leo 2026-10-21: a 720-turn chat stopped paging for good).
const settled = () => {
if (ask !== olderAsk.current) return;
olderAsking.current = false;
clearTimeout(olderRecheck.current);
olderRecheck.current = setTimeout(() => {
if (olderLoadRequested.current !== asked) return;
olderLoadRequested.current = undefined;
checkOlderNow.current();
}, OLDER_RECHECK_MS);
};
onLoadOlder().then(settled, settled);
};
useLayoutEffect(() => {
checkOlderNow.current = checkOlder;
});
useEffect(() => {
checkOlderNow.current();
}, [canLoadOlder, height, onLoadOlder, olderLoadKey, renderScroll.top]);
useEffect(() => () => clearTimeout(olderRecheck.current), []);
// A wheel up with the view already at the top moves nothing, so no scroll event says the reader
// wants more: it asks itself, unless an ask is still out.
const onWheel = (event: React.WheelEvent) => {
if (event.deltaY >= 0 || event.currentTarget.scrollTop > 0 || olderAsking.current) return;
olderLoadRequested.current = undefined;
checkOlderNow.current();
};
const pointAt = (target: EventTarget | null) => {
const row = target instanceof Element ? target.closest(".acpmux-row") : null;
const rowId = row?.dataset.rowId;
if (rowId === pointedRow.current && rows === pointedRows.current) return;
pointedRow.current = rowId;
pointedRows.current = rows;
const from = row ? Number(row.getAttribute("aria-posinset")) - 1 : -1;
// A prompt shows its own row, never its turn's too: one action row at a time.
const footer = from >= 0 && rows[from]!.kind !== "user" ? turnFooterFrom(rows, from) : undefined;
if (footer === pointedTurn.current) return;
pointedTurn.current = footer;
markTurns();
};
const behind = !seeking && !renderFollowing && layout.totalHeight - height - renderScroll.top > JUMP_LATEST_DISTANCE;
if (renderStart > 0) acpmuxPerf.span("vt.body", performance.now() - renderStart);
return (
<>
<div
ref={ref}
className="acpmux-scroll"
role="feed"
// Its place comes back after a web update's reload (reportAnchor, pages/shared/webSwap.ts).
{...(onAnchor ? { [WEB_SWAP_KEPT_SCROLL]: "kept" } : {})}
aria-label={t("transcript.label")}
onScroll={onScroll}
onWheel={onWheel}

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "nl -ba files/webviews/src/agent-session/acpmux/wheelGlide.ts | sed -n '1,240p'; printf '\nPASS\n'; nl -ba files/webviews/src/agent-session/acpmux/wheelPassThrough.ts | sed -n '1,240p'; printf '\nMOTION\n'; nl -ba files/webviews/src/agent-session/acpmux/threadMinimap/scrollMotion.ts | sed -n '1,260p'; printf '\nAGENT TEST\n'; nl -ba files/webviews/test/agent-pane-wheel-glide.test.ts | sed -n '1,280p'" in /tmp/review-27311-ac90
succeeded in 0ms:
1 // A notched wheel eases its distance; precise trackpads and Mos stay native (Leo, 2026-10-22).
2 import { MOTION_MS } from "../../motion/scale.generated";
3 import { wheelTarget } from "../../scrollers";
4 import { isAgentMotionOff } from "../shared/motion";
5 import { animateScrollBy, reducedMotion, stopScroll } from "./threadMinimap/scrollMotion";
6 import { wheelPixels } from "./wheelPassThrough";
7
8 // WebKit's mouse-wheel lines use 16.16 fixed point and 40 px per line; precise input uses points.
9 // Ambiguous input stays native. Chromium distinguishes notches with legacy 120-unit wheel ticks.
10 export function isWheelNotch(event: Pick<WheelEvent, "deltaY" | "deltaMode"> & { wheelDeltaY?: number }): boolean {
11 if (!event.deltaY) return false;
12 if (event.deltaMode !== 0) return true;
13 const legacy = event.wheelDeltaY;
14 if (!legacy) return false;
15 if (Math.abs(Math.abs(legacy) - 3 * Math.abs(event.deltaY)) < 1) {
16 if (Number.isInteger(event.deltaY * 64)) return false;
17 const fixed = (Math.abs(event.deltaY) / 40) * 65536;
18 return Math.abs(fixed - Math.round(fixed)) < 1e-3;
19 }
20 return legacy % 120 === 0;
21 }
22
23 /// Owns only notch input, on the existing single scroll clock. No anchor or follow-state writes.
24 /// A replacement jump, native wheel, other input, hidden page/pane, session switch or removal ends
25 /// the glide; the shared runner cancels its frame and listeners. The installer removes its wheel
26 /// listeners on unmount. Completed notches leave no timer or listener behind.
27 ///
28 /// Only a notched wheel needs to cancel the browser's scroll, and a wheel listener that may cancel
29 /// makes WebKit send every wheel event to the page before it scrolls: a trackpad's scroll then
30 /// waited for whatever the page was doing (an older page of history landing), and stopped (Leo
31 /// 2026-10-29: "why is it sometimes blocking me from scrolling up further?"). So a passive listener
32 /// watches the wheel, and the cancelling one is there until the first precise (trackpad, Mos) event
33 /// and again from the next notch, which scrolls natively.
34 export function installWheelGlide(node: HTMLElement): { stop(): void; remove(): void } {
35 let ride: { remaining(): number } | undefined;
36 const finished = () => {
37 ride = undefined;
38 };
39 const stop = () => {
40 if (ride) stopScroll(node);
41 ride = undefined;
42 };
43 const onWheel = (event: WheelEvent) => {
44 const root = node.ownerDocument.documentElement;
45 if (
46 event.defaultPrevented ||
47 !event.cancelable ||
48 event.ctrlKey ||
49 event.metaKey ||
50 event.shiftKey ||
51 event.altKey ||
52 Math.abs(event.deltaY) <= Math.abs(event.deltaX) ||
53 !isWheelNotch(event) ||
54 wheelTarget(event) !== node ||
55 root.dataset.wheelGlide === "off" ||
56 isAgentMotionOff() ||
57 reducedMotion()
58 ) {
59 stop();
60 return;
61 }
62 const token =
63 node.ownerDocument.defaultView?.getComputedStyle(node).getPropertyValue("--agent-motion-move").trim() ?? "";
64 const number = Number.parseFloat(token);
65 const duration = Number.isFinite(number) ? number * (token.endsWith("ms") ? 1 : 1000) : MOTION_MS.standard;
66 if (duration <= 0) {
67 stop();
68 return;
69 }
70 const max = Math.max(0, node.scrollHeight - node.clientHeight);
71 const wanted = node.scrollTop + (ride?.remaining() ?? 0) + wheelPixels(event, node.clientHeight);
72 const distance = Math.max(0, Math.min(max, wanted)) - node.scrollTop;
73 // Equal reverse notches cancel the pending motion, rather than falling through to the browser.
74 if (Math.abs(distance) < 0.5) {
75 if (ride) {
76 event.preventDefault();
77 stop();
78 }
79 return;
80 }
81 event.preventDefault();
82 ride = animateScrollBy(node, distance, duration, [0.22, 1, 0.36, 1], wanted >= max, finished);
83 };
84 let armed = true;
85 node.addEventListener("wheel", onWheel, { passive: false });
86 const watch = (event: WheelEvent) => {
87 if (!event.deltaY || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
88 const notch = isWheelNotch(event);
89 if (notch === armed) return;
90 armed = notch;
91 if (notch) node.addEventListener("wheel", onWheel, { passive: false });
92 else node.removeEventListener("wheel", onWheel);
93 };
94 node.addEventListener("wheel", watch, { passive: true });
95 return {
96 stop,
97 remove: () => {
98 stop();
99 node.removeEventListener("wheel", watch);
100 node.removeEventListener("wheel", onWheel);
101 armed = false;
102 },
103 };
104 }

PASS
1 // The wheel over Jump to latest scrolls the transcript (Leo 2026-10-19: "can't scroll when cursor on
2 // the jump to bottom"), as it does in the Claude and Codex apps: the button floats over the
3 // transcript's foot but is not inside its scroller, so a wheel over it reached nothing. The bottom
4 // stack (questions, notices, the queue, the composer and its footer) is a no-scroll zone (Leo
5 // 2026-10-20: "all of these areas are essentially a no-scroll zone"): the wheel over its parts
6 // scrolls neither the transcript nor the stack, and a part that scrolls itself (a long prompt, the
7 // queue, a menu's list) keeps the wheel at its ends too. The gutters and empty room beside each part's
8 // card are not part of it (Leo 2026-10-20: "if it's not in the composer it should just scroll"): the
9 // composer passes the pointer through beside its card (bottomStack.css), and the other parts' bands,
10 // which take the pointer so nothing reaches the masked rows under them, send the wheel on here.
11
12 /// The event's vertical distance in pixels (a line is 16px, a page the scroller's height).
13 export function wheelPixels(event: Pick<WheelEvent, "deltaY" | "deltaMode">, pageHeight: number): number {
14 if (event.deltaMode === 1) return event.deltaY * 16;
15 if (event.deltaMode === 2) return event.deltaY * pageHeight;
16 return event.deltaY;
17 }
18
19 /// Whether the wheel over target goes to the transcript: over Jump to latest, or over a part of the
20 /// floating stack itself (its gutters and empty room, not what it holds), unless that part scrolls.
21 /// The composer passes the pointer through instead, and the dictation notice is a card of its own.
22 function passesThrough(target: Element): boolean {
23 if (target.closest(".acpmux-jump-latest")) return true;
24 if (!target.parentElement?.matches(".acpmux-main[data-dock=float] > .acpmux-dock")) return false;
25 if (target.matches(".acpmux-composer, .acpmux-dictation-notice")) return false;
26 return target.scrollHeight <= target.clientHeight;
27 }
28
29 /// Scrolls scroller by a vertical wheel that landed outside it. The scroller first gets a copy of
30 /// the event, so its own wheel listeners run as for a wheel over it (the scroll counts as the
31 /// reader's, a glide, a minimap jump or a Jump to latest in flight ends). A pinch (ctrl) or a mostly
32 /// sideways wheel is left alone.
33 export function forwardWheel(scroller: HTMLElement, event: WheelEvent): void {
34 if (event.defaultPrevented || event.ctrlKey) return;
35 if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
36 const copy = new WheelEvent("wheel", {
37 deltaX: event.deltaX,
38 deltaY: event.deltaY,
39 deltaMode: event.deltaMode,
40 ctrlKey: event.ctrlKey,
41 metaKey: event.metaKey,
42 shiftKey: event.shiftKey,
43 altKey: event.altKey,
44 cancelable: true,
45 });
46 Object.defineProperty(copy, "wheelDeltaY", { value: (event as WheelEvent & { wheelDeltaY?: number }).wheelDeltaY });
47 scroller.dispatchEvent(copy);
48 if (!copy.defaultPrevented) scroller.scrollTop += wheelPixels(event, scroller.clientHeight);
49 }
50
51 /// Sends the wheel over Jump to latest, or over a floating stack part's empty room, in main to
52 /// scroller (forwardWheel). The listener is passive: a wheel over the transcript itself never
53 /// waits on it. Returns the remover.
54 export function installWheelPassThrough(scroller: HTMLElement, main: HTMLElement): () => void {
55 const onWheel = (event: WheelEvent) => {
56 const target = event.target instanceof Element ? event.target : null;
57 if (!target || !passesThrough(target) || scroller.contains(target)) return;
58 forwardWheel(scroller, event);
59 };
60 main.addEventListener("wheel", onWheel, { passive: true });
61 return () => main.removeEventListener("wheel", onWheel);
62 }

MOTION
1 // Scroll motion for the thread minimap: a JS-driven scroll on a cubic-bezier curve (the browser's
2 // smooth scroll cannot take a curve), the landing highlight on a prompt's bubble, and the Reduce
3 // Motion check both use. One scroll runs per scroller; the reader's own wheel, touch, pointer or key
4 // ends it where it is.
5 import { bezierAt, type CubicBezier } from "../../../ui/cubicBezier";
6 import { HIGHLIGHT_EASING, HIGHLIGHT_MS } from "./model";
7
8 const running = new WeakMap<HTMLElement, () => void>();
9
10 /// Sent (not bubbling) on a scroller right after this module's motion writes its offset from a frame
11 /// callback. The browser's own scroll event for that write comes on the next frame, after this one
12 /// paints, so what must move with the scroll on its own frame (the minimap's band) listens for this too.
13 export const MOTION_SCROLL_EVENT = "acpmux-motion-scroll";
14
15 /// Tells node's listeners that the motion just moved it (MOTION_SCROLL_EVENT).
16 function announceScroll(node: HTMLElement): void {
17 // A stand-in scroller (a unit test's) may have no document or events of its own.
18 if (typeof node.dispatchEvent !== "function") return;
19 const Event_ = node.ownerDocument?.defaultView?.Event ?? globalThis.Event;
20 if (Event_) node.dispatchEvent(new Event_(MOTION_SCROLL_EVENT));
21 }
22 const READER_INPUTS = ["wheel", "pointerdown", "touchstart", "keydown"] as const;
23
24 /// Reduce Motion: the system's, or the app's (its motion tokens are 0 when Reduce Motion is on or
25 /// ui.animationSpeed is off; AgentPaneTheme.motion).
26 export function reducedMotion(): boolean {
27 if (globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return true;
28 try {
29 const value = getComputedStyle(document.documentElement).getPropertyValue("--agent-motion-in").trim();
30 return value === "0s" || value === "0ms";
31 } catch {
32 return false;
33 }
34 }
35
36 /// Ends the scroll running on node, if any, where it is.
37 export function stopScroll(node: HTMLElement): void {
38 running.get(node)?.();
39 }
40
41 /// Scrolls node to target() over duration ms on curve. The target is read each frame, so rows
42 /// that measure meanwhile move the landing with them. A zero duration jumps. Replacing or cancelling
43 /// the ride notifies cancelled; a completed ride calls only then.
44 export function animateScroll(
45 node: HTMLElement,
46 target: () => number,
47 duration: number,
48 curve: CubicBezier,
49 then?: () => void,
50 cancelled?: () => void,
51 ): void {
52 stopScroll(node);
53 const from = node.scrollTop;
54 runScroll(
55 node,
56 duration,
57 curve,
58 (progress) => {
59 node.scrollTop = from + (target() - from) * progress;
60 },
61 then,
62 cancelled,
63 );
64 }
65
66 /// An explicit mouse-wheel distance on the same clock as jumps. Each step adds only its own delta
67 /// to the live offset: the existing row-id anchor owns layout/prepend corrections. A notch headed
68 /// to the bottom follows that moving end; one parked in history keeps exactly its wheel distance.
69 export function animateScrollBy(
70 node: HTMLElement,
71 distance: number,
72 duration: number,
73 curve: CubicBezier,
74 toEnd: boolean,
75 finished: () => void,
76 ): { remaining(): number } {
77 let done = 0;
78 let previous = 0;
79 const left = () => (toEnd ? Math.max(0, node.scrollHeight - node.clientHeight - node.scrollTop) : distance - done);
80 runScroll(
81 node,
82 duration,
83 curve,
84 (progress) => {
85 const fraction = previous === 1 ? 1 : (progress - previous) / (1 - previous);
86 // Quantize only cumulative mouse-wheel travel. Each incremental write is a whole pixel,
87 // so WebKit cannot floor a fractional tail past the target and bounce back. The live
88 // offset (including the row-id anchor's own corrections) is never rounded or replaced.
89 const next = Math.round(distance * progress);
90 node.scrollTop += toEnd ? left() * fraction : next - done;
91 done = next;
92 previous = progress;
93 },
94 finished,
95 finished,
96 true,
97 );
98 return { remaining: left };
99 }
100
101 /// One owner, clock and cleanup path for both jumps and notches. The wheel installer arbitrates
102 /// wheel events itself; the runner owns all other cancellation inputs for that explicit motion.
103 function runScroll(
104 node: HTMLElement,
105 duration: number,
106 curve: CubicBezier,
107 write: (progress: number) => void,
108 then?: () => void,
109 cancelled?: () => void,
110 wheel = false,
111 ): void {
112 stopScroll(node);
113 if (duration <= 0 || typeof requestAnimationFrame !== "function") {
114 write(1);
115 then?.();
116 return;
117 }
118 const doc = node.ownerDocument;
119 const owner = wheel ? (doc.defaultView ?? node) : node;
120 const inputs = wheel ? READER_INPUTS.filter((input) => input !== "wheel") : READER_INPUTS;
121 const start = performance.now();
122 let frame = 0;
123 let active = true;
124 const cleanup = () => {
125 active = false;
126 cancelAnimationFrame(frame);
127 for (const name of inputs) owner.removeEventListener(name, end, wheel);
128 if (wheel) doc.removeEventListener("visibilitychange", hidden);
129 if (running.get(node) === end) running.delete(node);
130 };
131 const end = () => {
132 if (!active) return;
133 cleanup();
134 cancelled?.();
135 };
136 const hidden = () => {
137 if (doc.hidden) end();
138 };
139 const step = (now: number) => {
140 if (wheel && (doc.hidden || node.clientHeight === 0)) {
141 end();
142 return;
143 }
144 const progress = Math.min(1, Math.max(0, (now - start) / duration));
145 write(bezierAt(curve, progress));
146 announceScroll(node);
147 if (!active) return;
148 if (progress < 1) frame = requestAnimationFrame(step);
149 else {
150 cleanup();
151 then?.();
152 }
153 };
154 for (const name of inputs) owner.addEventListener(name, end, { capture: wheel, passive: true });
155 if (wheel) doc.addEventListener("visibilitychange", hidden);
156 running.set(node, end);
157 frame = requestAnimationFrame(step);
158 }
159
160 /// Holds a jump's landing while the rows it crossed mount and measure: each frame, offset() says how
161 /// far the target sits from where it should land (undefined while it is not mounted), and the scroll
162 /// moves by that much, until it holds for three frames or frames run out. Any input anywhere in the
163 /// page (a wheel, a press, a key: the reader scrolling, Find, Jump to Latest) or another scroll on
164 /// node ends it.
165 export function settleScroll(node: HTMLElement, offset: () => number | undefined, frames: number): void {
166 stopScroll(node);
167 if (typeof requestAnimationFrame !== "function") return;
168 let frame = 0;
169 let left = frames;
170 let steady = 0;
171 const page = node.ownerDocument.defaultView ?? window;
172 const end = () => {
173 cancelAnimationFrame(frame);
174 for (const name of READER_INPUTS) page.removeEventListener(name, end, true);
175 if (running.get(node) === end) running.delete(node);
176 };
177 const step = () => {
178 // A scroller with no layout (hidden, or a DOM without one) has nothing to measure.
179 const by = node.clientHeight > 0 ? offset() : undefined;
180 if (by !== undefined) {
181 const max = Math.max(0, node.scrollHeight - node.clientHeight);
182 const to = Math.max(0, Math.min(max, node.scrollTop + by));
183 if (Math.abs(to - node.scrollTop) > 0.5) {
184 node.scrollTop = to;
185 announceScroll(node);
186 steady = 0;
187 } else steady += 1;
188 }
189 left -= 1;
190 if (steady >= 3 || left <= 0) end();
191 else frame = requestAnimationFrame(step);
192 };
193 // Captured on the window: added during the input that started the jump, it does not see that one.
194 for (const name of READER_INPUTS) page.addEventListener(name, end, { capture: true, passive: true });
195 running.set(node, end);
196 frame = requestAnimationFrame(step);
197 }
198
199 /// The landing highlight: the bubble's background starts at --acpmux-landing-highlight and eases
200 /// back to its own over HIGHLIGHT_MS. None under Reduce Motion.
201 export function highlightBubble(bubble: HTMLElement | null | undefined): void {
202 if (!bubble || typeof bubble.animate !== "function" || reducedMotion()) return;
203 const style = getComputedStyle(bubble);
204 const highlight = style.getPropertyValue("--acpmux-landing-highlight").trim();
205 if (!highlight) return;
206 for (const animation of bubble.getAnimations?.() ?? []) if (animation.id === "acpmux-landing") animation.cancel();
207 const animation = bubble.animate(
208 [
209 { backgroundColor: highlight },
210 { backgroundColor: highlight, offset: 0.35 },
211 { backgroundColor: style.backgroundColor },
212 ],
213 { duration: HIGHLIGHT_MS, easing: HIGHLIGHT_EASING },
214 );
215 animation.id = "acpmux-landing";
216 }

AGENT TEST
1 // cmux-web-test-timeout: 420
2 // l10n-allow-file: wheel-motion fixtures, not shipped text.
3 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
4 import { mkdir, writeFile } from "node:fs/promises";
5 import { chromium, webkit, type Browser } from "playwright";
6 import { papercutPane } from "./support/papercutChat";
7 import { requireBrowserLane } from "./support/requireBrowserLane";
8
9 await requireBrowserLane("agent-pane-wheel-glide.test.ts", async () => {
10 setDefaultTimeout(120_000);
11 let browser: Browser;
12 let pane: Awaited<ReturnType>;
13 beforeAll(async () => {
14 browser = await chromium.launch();
15 pane = await papercutPane(() => browser, { turns: 60, running: false });
16 });
17 afterAll(async () => {
18 await pane?.close();
19 await browser?.close();
20 });
21 for (const engine of ["chromium", "webkit"] as const) {
22 describe(engine, () => {
23 test("a notch covers its distance over several painted frames", async () => {
24 await browser.close();
25 browser = await (engine === "chromium" ? chromium : webkit).launch();
26 if (process.env.CMUX_WHEEL_TRACE_DIR) {
27 const openPage = browser.newPage.bind(browser);
28 browser.newPage = (options) =>
29 openPage({
30 ...options,
31 recordVideo: {
32 dir: process.env.CMUX_WHEEL_TRACE_DIR!,
33 size: { width: 900, height: 700 },
34 },
35 });
36 }
37 const page = await pane.open();
38 try {
39 const trace = await page.evaluate(async (engine) => {
40 const node = document.querySelector(".acpmux-scroll")!;
41 const values = [node.scrollTop];
42 const times = [performance.now()];
43 const prevented: boolean[] = [];
44 node.addEventListener("wheel", (event) => prevented.push(event.defaultPrevented));
45 (window as any).__wheelTrace = { values, times, prevented };
46 window.cmuxAfterPaint(() => {
47 values.push(node.scrollTop);
48 times.push(performance.now());
49 return values.length < 35;
50 });
51 if (engine === "webkit") {
52 // macOS WebKit's non-precise wheel: accelerated lines use 16.16 fixed point.
53 const event = new WheelEvent("wheel", { deltaY: -100.006103515625, cancelable: true });
54 Object.defineProperty(event, "wheelDeltaY", { value: 300 });
55 node.dispatchEvent(event);
56 }
57 return { start: node.scrollTop };
58 }, engine);
59 if (engine === "chromium") {
60 const box = await page.locator(".acpmux-scroll").boundingBox();
61 await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
62 await page.mouse.wheel(0, -100);
63 }
64 await page.waitForFunction(() => (window as any).__wheelTrace.values.length === 35);
65 const result = await page.evaluate(
66 () =>
67 (window as any).__wheelTrace as {
68 values: number[];
69 times: number[];
70 prevented: boolean[];
71 },
72 );
73 const steps = result.values.slice(1).map((top, i) => Math.abs(top - result.values[i]!));
74 const moved = steps.filter((by) => by > 0.5);
75 const metrics = {
76 engine,
77 start: trace.start,
78 end: result.values.at(-1),
79 movingFrames: moved.length,
80 maxStep: Math.max(...steps),
81 prevented: result.prevented,
82 values: result.values,
83 };
84 console.log("wheel-glide-trace", JSON.stringify(metrics));
85 if (process.env.CMUX_WHEEL_TRACE_DIR) {
86 await mkdir(process.env.CMUX_WHEEL_TRACE_DIR, { recursive: true });
87 await writeFile(${process.env.CMUX_WHEEL_TRACE_DIR}/${engine}.json, JSON.stringify(metrics));
88 await page.screenshot({ path: ${process.env.CMUX_WHEEL_TRACE_DIR}/${engine}.png });
89 }
90 expect(result.prevented).toContain(true);
91 expect(result.values.slice(1).every((top, index) => top <= result.values[index]!)).toBe(true);
92 expect(Math.abs(result.values.at(-1)! - result.values[0]!)).toBeCloseTo(100, 0);
93 expect(moved.length).toBeGreaterThanOrEqual(3);
94 expect(Math.max(...steps)).toBeLessThan(60);
95 } finally {
96 await page.close();
97 if (process.env.CMUX_WHEEL_TRACE_DIR && page.video())
98 await page.video()!.saveAs(${process.env.CMUX_WHEEL_TRACE_DIR}/${engine}.webm);
99 }
100 });
101 test("an asynchronous send takes the view from an unfinished wheel glide", async () => {
102 const page = await pane.open();
103 try {
104 const gaps = await page.evaluate(async () => {
105 const node = document.querySelector(".acpmux-scroll")!;
106 node.style.setProperty("--agent-motion-move", "1s");
107 node.dispatchEvent(new WheelEvent("wheel", { deltaY: -20, deltaMode: 1, cancelable: true }));
108 await window.cmuxPaintFrame();
109 await window.cmuxAcpmuxActions!["chat.send"]!({ text: "Wheel ownership" });
110 const gaps: number[] = [];
111 for (let i = 0; i < 30; i++) {
112 await window.cmuxPaintFrame();
113 gaps.push(node.scrollHeight - node.clientHeight - node.scrollTop);
114 }
115 return gaps;
116 });
117 expect(Math.max(...gaps.slice(3))).toBeLessThanOrEqual(1);
118 } finally {
119 await page.close();
120 }
121 });
122 test("precise, modified and reduced-motion wheels remain native", async () => {
123 const page = await pane.open();
124 try {
125 const result = await page.evaluate(() => {
126 const node = document.querySelector(".acpmux-scroll")!;
127 const wheel = (deltaY: number, extra: WheelEventInit = {}) => {
128 const event = new WheelEvent("wheel", { deltaY, cancelable: true, ...extra });
129 Object.defineProperty(event, "wheelDeltaY", { value: -deltaY * 3 });
130 node.dispatchEvent(event);
131 return event.defaultPrevented;
132 };
133 const precise = wheel(-40);
134 const modified = wheel(-100.006103515625, { metaKey: true });
135 document.documentElement.dataset.agentMotion = "off";
136 const motionOff = wheel(-100.006103515625);
137 return { precise, modified, motionOff };
138 });
139 expect(result).toEqual({ precise: false, modified: false, motionOff: false });
140 } finally {
141 await page.close();
142 }
143 });
144 });
145 }
146 });

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '1850,2025p'; nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '3870,4025p'; printf '\nTEST FULL\n'; nl -ba files/webviews/test/transcript-older-scroll.test.ts | sed -n '1,220p'; printf '\nREOPEN\n'; nl -ba files/webviews/test/reopened-chat-history.test.ts | sed -n '320,410p'" in /tmp/review-27311-ac90
succeeded in 0ms:
1850 }
1851 clear() {
1852 this.pending.clear();
1853 }
1854 /// Releases the first copy's observation when it intersects; returns the unwatch.
1855 watch(node: HTMLElement, shown: () => void): () => void {
1856 if (typeof IntersectionObserver === "undefined") {
1857 shown();
1858 return () => {};
1859 }
1860 this.intersections ??= new IntersectionObserver(
1861 (entries) => {
1862 for (const entry of entries) {
1863 if (!entry.isIntersecting) continue;
1864 const target = entry.target as HTMLElement;
1865 target.dataset.olderShown = "true";
1866 this.intersections?.unobserve(target);
1867 this.waiting.get(target)?.();
1868 this.waiting.delete(target);
1869 }
1870 },
1871 { root: this.root() },
1872 );
1873 this.waiting.set(node, shown);
1874 this.intersections.observe(node);
1875 return () => {
1876 this.waiting.delete(node);
1877 this.intersections?.unobserve(node);
1878 };
1879 }
1880 disconnect() {
1881 this.intersections?.disconnect();
1882 this.intersections = undefined;
1883 this.waiting.clear();
1884 }
1885 }
1886 /// How far around the last mounted range a row whose drawn height changed is looked for; farther
1887 /// (a jump), the whole transcript is placed again.
1888 const DRAWN_SEARCH_ROWS = 64;
1889 const MAX_SCROLL_LEAD_VIEWPORTS = 4;
1890 /// Viewports of rows kept mounted either side of the reader, built between frames.
1891 const WARM_VIEWPORTS = 1;
1892 /// How far above the latest message the reader is before Jump to latest shows.
1893 const JUMP_LATEST_DISTANCE = 80;
1894 /// How long a scroll event can be attributed to the reader's most recent input. Expiring this
1895 /// window never changes the reader's follow choice; it only distinguishes subsequent events.
1896 const READER_SCROLL_MS = 1000;
1897 /// How long a followed view keeps the room a queued prompt's card left, for the prompt to arrive.
1898 const FLOOR_HOLD_MS = 1000;
1899 /// How long it keeps the room a withdrawn prompt's card left after the reply last grew: a reply
1900 /// streaming fills it, and a turn in a tool call or thinking brings nothing to fill it with.
1901 const WITHDRAWN_HOLD_MS = 250;
1902 /// How long a reopened chat stays unseen while older pages bring back the row the reader left it on
1903 /// (cx-odzaz): each page must land within OPEN_AT_PAGE_MS and the whole walk within OPEN_AT_SEEK_MS,
1904 /// else the chat opens at its latest message.
1905 const OPEN_AT_PAGE_MS = 1500;
1906 const OPEN_AT_SEEK_MS = 8000;
1907 /// How soon the transcript asks again for an older page after an ask that brought nothing.
1908 const OLDER_RECHECK_MS = 500;
1909 /// How near the first loaded row, in viewports, the view asks for the next older page: far enough
1910 /// ahead that a page read from acpmux lands before a fast scroll up reaches the top (two viewports
1911 /// was a fraction of a second of a flick).
1912 const OLDER_AHEAD_VIEWPORTS = 6;
1913 /// Scrolls scroller so range sits a third of the way down it, when it is not already in view
1914 /// above the inset px at its foot that the bottom stack covers.
1915 function scrollRangeIn(scroller: HTMLElement, range: Range, inset = 0) {
1916 const box = range.getBoundingClientRect();
1917 const view = scroller.getBoundingClientRect();
1918 if (box.top < view.top || box.bottom > view.bottom - inset)
1919 scroller.scrollTop += box.top - view.top - scroller.clientHeight / 3;
1920 }
1921 /// Scrolls range into view in the scrollers between it and transcript (a command's capped
1922 /// output scrolls on its own), innermost first; the transcript itself is left to the caller.
1923 function scrollRangeInBoxes(transcript: HTMLElement, range: Range) {
1924 for (let box = range.startContainer.parentElement; box && box !== transcript; box = box.parentElement)
1925 if (box.scrollHeight > box.clientHeight && /auto|scroll/.test(getComputedStyle(box).overflowY))
1926 scrollRangeIn(box, range);
1927 }
1928 /// How long Find keeps a new match in view while its groups open and the rows above it measure:
1929 /// at least 600 ms and 36 frames, so a page the machine starves (fewer frames) still holds the
1930 /// match through the measures that come late. A page hidden meanwhile draws no frames and finishes
1931 /// the frames it has left once shown.
1932 const FIND_REVEAL_MS = 600;
1933 const FIND_REVEAL_FRAMES = 36;
1934 /// ...and until the match has not moved for this many frames: on a starved page the fold it opened
1935 /// measures for seconds, each measure pushing the match down.
1936 const FIND_REVEAL_STILL_FRAMES = 12;
1937 /// A layout that never settles (a reply streaming above the match) ends the hold after this many.
1938 const FIND_REVEAL_MAX_FRAMES = 600;
1939 /// The current Find match's range, if it is drawn.
1940 function findActiveRange(): Range | undefined {
1941 const highlights = (globalThis.CSS as unknown as { highlights?: Map<string, Set> } | undefined)?.highlights;
1942 return [...(highlights?.get(FIND_ACTIVE_HIGHLIGHT) ?? [])][0];
1943 }
1944 /// Where the current Find match sits in scroller's content, if it is drawn.
1945 function findMatchOffset(scroller: HTMLElement): number | undefined {
1946 const range = findActiveRange();
1947 if (!range) return undefined;
1948 return range.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
1949 }
1950 /// A scroll that rests this long is where the chat reopens (cx-6bgzj), reported once, not per frame.
1951 export const SCROLL_ANCHOR_SETTLE_MS = 400;
1952 /// Where a chat's transcript rests: the row at the top of the view and how far into it; null is the
1953 /// latest message, which a reopened chat follows anyway.
1954 export type ScrollAnchor = { rowId: string; offset: number };
1955
1956 /// Where a transcript scrolled to top rests: null at its latest message.
1957 function restingAnchor(
1958 top: number,
1959 atLatest: boolean,
1960 placed: Parameters[0],
1961 shown: readonly { id: string }[],
1962 ): ScrollAnchor | null {
1963 const index = visibleLayoutRange(placed, top, 0, 0).first;
1964 const row = shown[index];
1965 return atLatest || !row ? null : { rowId: row.id, offset: top - placed.tops[index] };
1966 }
1967
1968 /// Jump to latest: a glass circle centered above the composer, shown while the reader is scrolled up
1969 /// from the latest message (ChatGPT and Codex style). While the agent works it stays a static arrow;
1970 /// the working state is already represented by the shared transcript spinner. It stays mounted while
1971 /// hidden (inert) so it fades out as it fades in (conversation.css).
1972 function JumpToLatest({
1973 shown,
1974 working,
1975 onJump,
1976 lift,
1977 needsYou,
1978 }: {
1979 shown: boolean;
1980 working: boolean;
1981 /// Open asks (needsYou/JumpPill.ts): the button becomes the Needs your input pill.
1982 needsYou?: TranscriptNeedsYou;
1983 /// The bottom stack's height over the transcript's foot: the button sits above it.
1984 lift: number;
1985 /// focused: the button had keyboard focus, which it loses when it hides at the latest message.
1986 onJump: (focused: boolean) => void;
1987 }) {
1988 const t = useT();
1989 if (needsYou && needsYou.count > 0) return <NeedsYouPill {...needsYou} shown={shown} lift={lift} onJump={onJump} />;
1990 const label = t(working ? "transcript.jumpLatestWorking" : "transcript.jumpLatest");
1991 return (
1992 <button
1993 type="button"
1994 className="ui-glass ui-glass-floats acpmux-jump-latest"
1995 data-shown={shown ? "" : undefined}
1996 style={lift > 0 ? ({ "--acpmux-dock-space": ${lift}px } as React.CSSProperties) : undefined}
1997 aria-label={label}
1998 // The arrow says nothing by itself; working state is carried by the shared spinner in the transcript.
1999 title={label}
2000 // Hidden, it is inert: out of the tab order and the accessibility tree while it fades out.
2001 inert={!shown}
2002 // A click does not focus a button in WebKit, so focus on it means Return or Space.
2003 onClick={(event) => onJump(event.currentTarget === document.activeElement)}
2004 >
2005
2006
2007

2008
2009 );
2010 }
2011
2012 /// What the transcript's rows read from it: Find's reveal target and, for a prompt's own bookmark
2013 /// button, the session its marks key into (threadMinimap/promptMarks.ts).
2014 function TranscriptContext({
2015 find,
2016 sessionId,
2017 children,
2018 }: {
2019 find: React.ContextType;
2020 sessionId?: string;
2021 children: React.ReactNode;
2022 }) {
2023 return (
2024 <FindReveal.Provider value={find}>
2025 <PromptMarksSession.Provider value={sessionId ?? ""}>{children}</PromptMarksSession.Provider>
3870 if (!node || typeof header?.closest !== "function" || !node.contains(header) || !header.closest(".acpmux-row"))
3871 return;
3872 event.preventDefault();
3873 settleFold(header);
3874 foldAnchor.current = holdFold(header);
3875 };
3876 // The reader scrolling lets go of it.
3877 const release = (event: Event) => {
3878 const key = (event as KeyboardEvent).key;
3879 if (key === "Enter" || key === " ") return;
3880 foldAnchor.current = undefined;
3881 };
3882 const releases = ["wheel", "touchstart", "keydown"] as const;
3883 node?.addEventListener("click", holdClicked, { capture: true });
3884 node?.addEventListener(ANCHORED_COLLAPSE_EVENT, holdAsked);
3885 for (const name of releases) node?.addEventListener(name, release, { passive: true });
3886 // The wheel over Jump to latest scrolls the transcript (wheelPassThrough.ts).
3887 // The chat's own transcript only (a replay's sits in its own screen).
3888 const main = node?.closest(".acpmux-stage")?.parentElement?.closest(".acpmux-main");
3889 const removeWheel = node && main ? installWheelPassThrough(node, main) : undefined;
3890 // A drag from the empty margins either side of the column selects text (gutterSelect.ts); the
3891 // view it scrolls past an edge is the reader's scroll.
3892 // A chat change ends any drag first (gutterRelease, below), so a hold is always this chat's.
3893 const gutter =
3894 node && thread.current
3895 ? installGutterSelect(
3896 node,
3897 thread.current,
3898 reader,
3899 (held) =>
3900 setHeldRows(
3901 held
3902 ? {
3903 session: heldSession.current,
3904 rows: held.map((row) => ({ ...row, fromEnd: rowsRef.current.length - row.index })),
3905 }
3906 : undefined,
3907 ),
3908 main,
3909 )
3910 : undefined;
3911 gutterRelease.current = gutter?.release;
3912 wheelGlide.current = node ? installWheelGlide(node) : undefined;
3913 return () => {
3914 wheelGlide.current?.remove();
3915 wheelGlide.current = undefined;
3916 stopJump();
3917 for (const name of inputs) node?.removeEventListener(name, reader, { capture: true });
3918 ownerDocument?.removeEventListener("pointermove", readerMove, { capture: true });
3919 ownerDocument?.removeEventListener("pointerup", readerReleased, { capture: true });
3920 ownerDocument?.removeEventListener("pointercancel", readerReleased, { capture: true });
3921 node?.removeEventListener("click", holdClicked, { capture: true });
3922 node?.removeEventListener(ANCHORED_COLLAPSE_EVENT, holdAsked);
3923 for (const name of releases) node?.removeEventListener(name, release);
3924 removeWheel?.();
3925 gutterRelease.current = undefined;
3926 gutter?.remove();
3927 };
3928 }, [stopJump]);
3929 // A turn's footer (TurnFooter) shows for the turn the reader points at: anywhere after its prompt
3930 // to its footer. Marked on the mounted rows, so a hover renders nothing.
3931 // The last ended turn's prompt offers Retry under its bubble (UserBubble.tsx); turns.ts names
3932 // it on the turn's footer while it may be sent again.
3933 // Rows are immutable snapshots. Reuse the last ended turn's retry target across scroll and
3934 // focus commits, which do not change rows and otherwise walk the entire transcript.
3935 const retryPromptId = useMemo(() => {
3936 for (let index = rows.length - 1; index >= 0; index -= 1)
3937 if (rows[index]!.kind === "turnSummary") return rows[index]!.retryRowId;
3938 return undefined;
3939 }, [rows]);
3940 const markTurns = useCallback(() => {
3941 for (const node of thread.current?.querySelectorAll(".acpmux-row") ?? []) {
3942 node.toggleAttribute("data-retryable", node.dataset.rowId === retryPromptId);
3943 node.toggleAttribute("data-turn-revealed", node.dataset.rowId === pointedTurn.current);
3944 }
3945 }, [retryPromptId]);
3946 // Mark only when the mounted rows, their range, or the last turn changes. The old no-deps
3947 // layout effect scanned every commit, including scroll/follow state commits that cannot change
3948 // these attributes. pointAt still calls the callback directly for pointer-driven updates.
3949 useLayoutEffect(markTurns, [markTurns, rows, range.first, range.last]);
3950 // Older history pages in before the reader reaches it: whenever the view is within six viewports
3951 // of the first loaded row, including a tail too short to fill them. The anchoring above keeps the
3952 // reader's row in place as each page lands.
3953 // A read that drew no row above the first (records that draw nothing) moves historyFrom: ask again.
3954 const olderLoadKey = ${sessionKey}\u0000${rows[0]?.id ?? ""}\u0000${historyFrom ?? ""};
3955 const olderLoadRequested = useRef<string | undefined>(undefined);
3956 /// An older page asked for has not settled yet; and the check that follows one that brought nothing.
3957 const olderAsking = useRef(false);
3958 const olderRecheck = useRef<ReturnType>(undefined);
3959 /// The latest ask: only its settling re-arms the check, so a late answer to an earlier one (a
3960 /// chat switched away from) cannot cancel the recheck of the ask that followed it.
3961 const olderAsk = useRef(0);
3962 // The check reads the scroller's live offset, and runs on every scroll event as well as after
3963 // each commit: a page that lands moves the view down (the anchoring above) and the reader's
3964 // scroll back to the top could land in the same frame, so the rendered offset never changed
3965 // and a check that ran only on a change of it never asked for the next page (the scroll to the
3966 // first message stalled at the top after a few pages, perf100).
3967 const checkOlder = () => {
3968 const node = ref.current;
3969 if (!canLoadOlder || !onLoadOlder || !node || node.clientHeight === 0) {
3970 olderLoadRequested.current = undefined;
3971 return;
3972 }
3973 if (node.scrollTop >= node.clientHeight * OLDER_AHEAD_VIEWPORTS) {
3974 // Allow the next visit to the threshold to request the next page, even when the first row
3975 // did not change while the reader was away from the top.
3976 olderLoadRequested.current = undefined;
3977 return;
3978 }
3979 if (olderLoadRequested.current === olderLoadKey) return;
3980 const asked = olderLoadKey;
3981 olderLoadRequested.current = asked;
3982 olderAsking.current = true;
3983 const ask = ++olderAsk.current;
3984 // A page that lands changes the key in the commit it brings, and the check after it asks for
3985 // the next. An ask that brought nothing (a read that failed, or one the client dropped) leaves
3986 // the key as it was: ask once more a moment later, as long as the view is still near the top.
3987 // At the very top no scroll event comes to ask for it, and a check on every render would ask
3988 // on every streamed chunk (Leo 2026-10-21: a 720-turn chat stopped paging for good).
3989 const settled = () => {
3990 if (ask !== olderAsk.current) return;
3991 olderAsking.current = false;
3992 clearTimeout(olderRecheck.current);
3993 olderRecheck.current = setTimeout(() => {
3994 if (olderLoadRequested.current !== asked) return;
3995 olderLoadRequested.current = undefined;
3996 checkOlderNow.current();
3997 }, OLDER_RECHECK_MS);
3998 };
3999 onLoadOlder().then(settled, settled);
4000 };
4001 useLayoutEffect(() => {
4002 checkOlderNow.current = checkOlder;
4003 });
4004 useEffect(() => {
4005 checkOlderNow.current();
4006 }, [canLoadOlder, height, onLoadOlder, olderLoadKey, renderScroll.top]);
4007 useEffect(() => () => clearTimeout(olderRecheck.current), []);
4008 // A wheel up with the view already at the top moves nothing, so no scroll event says the reader
4009 // wants more: it asks itself, unless an ask is still out.
4010 const onWheel = (event: React.WheelEvent) => {
4011 if (event.deltaY >= 0 || event.currentTarget.scrollTop > 0 || olderAsking.current) return;
4012 olderLoadRequested.current = undefined;
4013 checkOlderNow.current();
4014 };
4015 const pointAt = (target: EventTarget | null) => {
4016 const row = target instanceof Element ? target.closest(".acpmux-row") : null;
4017 const rowId = row?.dataset.rowId;
4018 if (rowId === pointedRow.current && rows === pointedRows.current) return;
4019 pointedRow.current = rowId;
4020 pointedRows.current = rows;
4021 const from = row ? Number(row.getAttribute("aria-posinset")) - 1 : -1;
4022 // A prompt shows its own row, never its turn's too: one action row at a time.
4023 const footer = from >= 0 && rows[from]!.kind !== "user" ? turnFooterFrom(rows, from) : undefined;
4024 if (footer === pointedTurn.current) return;
4025 pointedTurn.current = footer;

TEST FULL
1 // Scrolling up a long chat asks for older history well before the top, so the view never stops
2 // there waiting for a page (Leo 2026-10-29: "scrolling up when it paginates and loads... didnt we
3 // previously have it pretty fast? why is it sometimes blocking me from scrolling up further?").
4 // The reader scrolls up with real wheel input, one step a frame, while each older page takes
5 // 400 ms to land (a read from acpmux).
6 //
7 // Real WebKit from Playwright; CI fails the file when it cannot launch (requireBrowserLane).
8 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
9 import path from "node:path";
10 import { webkit, type Browser, type Page } from "playwright";
11 import { installPaintedFrames } from "./support/paintedFrames";
12 import { requireBrowserLane } from "./support/requireBrowserLane";
13 import { warmViteServer } from "./support/warmDevServer";
14
15 await requireBrowserLane("transcript-older-scroll.test.ts", async () => {
16 setDefaultTimeout(120_000);
17 try {
18 await (await webkit.launch({ headless: true })).close();
19 } catch {
20 console.warn("transcript-older-scroll: skipping (run bunx playwright install webkit)");
21 describe.skip("transcript older scroll", () => test("webkit", () => {}));
22 return;
23 }
24 const { createServer } = await import("vite");
25 const webviews = path.resolve(import.meta.dir, "..");
26 let vite: Awaited<ReturnType> | undefined;
27 let browser: Browser | undefined;
28 beforeAll(async () => {
29 vite = await createServer({
30 root: webviews,
31 configFile: path.join(webviews, "vite.config.ts"),
32 server: { port: 0, host: "127.0.0.1", strictPort: false },
33 logLevel: "error",
34 });
35 await vite.listen();
36 await warmViteServer(vite, "/test/browser/transcript-older-scroll.html");
37 browser = await webkit.launch({ headless: true });
38 });
39 afterAll(async () => {
40 await browser?.close();
41 await vite?.close();
42 });
43
44 /// How far into the chat the view is: its offset less the height older pages added above the
45 /// first row it started on. A page that lands moves the offset and the content alike, so this moves
46 /// only with the reader's scroll (and with a correction that loses or adds to it).
47 const place = () => {
48 const scroller = document.querySelector(".acpmux-scroll")!;
49 const older = (window as unknown as { __older: { loaded(): number; total: number } }).__older;
50 return {
51 scrollTop: scroller.scrollTop,
52 scrollHeight: scroller.scrollHeight,
53 more: older.loaded() < older.total,
54 };
55 };
56
57 /// Scrolls up frames wheel steps of step px, one a frame, and measures how far the view went
58 /// into the chat against how far the wheel asked. WebKit applies a wheel step on its scrolling
59 /// thread and may report it to the page a frame later, so the travel is read once the scroll has
60 /// settled, never step by step.
61 const scrollUp = async (page: Page, step: number, frames: number) => {
62 let topFrames = 0;
63 let wheeled = 0;
64 await page.mouse.move(450, 350);
65 const start = (await page.evaluate(place)) as ReturnType;
66 for (let frame = 0; frame < frames; frame += 1) {
67 const now = (await page.evaluate(place)) as ReturnType;
68 if (now.more && now.scrollTop <= 1) topFrames += 1;
69 if (!now.more && now.scrollTop <= 1) break;
70 await page.mouse.wheel(0, -step);
71 wheeled += step;
72 await page.evaluate(() => window.cmuxPaintFrame());
73 }
74 for (let frame = 0; frame < 20; frame += 1) await page.evaluate(() => window.cmuxPaintFrame());
75 const end = (await page.evaluate(place)) as ReturnType;
76 const travel = start.scrollTop - end.scrollTop + (end.scrollHeight - start.scrollHeight);
77 return { wheeled, travel: Math.round(travel), lost: Math.round(wheeled - travel), topFrames };
78 };
79
80 for (const step of [40, 100]) {
81 test(a ${step} px-a-frame scroll up asks for older pages well before the top, async () => {
82 const { port } = vite!.httpServer!.address() as { port: number };
83 const page = await browser!.newPage({ viewport: { width: 900, height: 700 } });
84 await installPaintedFrames(page);
85 await page.goto(
86 http://127.0.0.1:${port}/test/browser/transcript-older-scroll.html${process.env.OLDER_SCROLL_ALL ? "?all" : ""},
87 );
88 await page.waitForFunction(() => document.querySelectorAll(".acpmux-row").length > 5);
89 // Notches glide over several frames; this measures a precise trackpad's step-a-frame scroll.
90 await page.evaluate(() => (document.documentElement.dataset.wheelGlide = "off"));
91 for (let frame = 0; frame < 10; frame += 1) await page.evaluate(() => window.cmuxPaintFrame());
92 const frames = 300;
93 const result = await scrollUp(page, step, frames);
94 const asks = await page.evaluate(() => {
95 const older = (window as unknown as { __older: { asks: { at: number; top: number; landed?: number }[] } })
96 .__older;
97 return older.asks.map((ask) => ({ top: Math.round(ask.top), ms: Math.round((ask.landed ?? NaN) - ask.at) }));
98 });
99 console.log(
100 transcript-older-scroll ${step}px: ${JSON.stringify({ ...result, asks: asks.length, first: asks.slice(0, 4) })},
101 );
102 if (!process.env.OLDER_SCROLL_ALL) expect(asks.length).toBeGreaterThan(1);
103 // Each page is asked for at least four viewports above the view (two left a flick a fraction
104 // of a second before the top), and the view never sat at the top waiting. The travel against
105 // the wheel is logged: headless WebKit drops some of a fast wheel's distance with no paging
106 // at all (OLDER_SCROLL_ALL), so it is not asserted.
107 expect(Math.min(...asks.map((ask) => ask.top))).toBeGreaterThanOrEqual(4 * 700);
108 expect(result.topFrames).toBe(0);
109 await page.close();
110 });
111 }
112 });

REOPEN
320 await vite.listen();
321 await warmViteServer(vite, "/");
322 browser = await chromium.launch({ headless: true });
323 for (let attempt = 1; ; attempt++) {
324 const warm = await browser.newPage();
325 try {
326 await warm.goto(url());
327 await warm.getByText("Answer 14.").waitFor({ timeout: 25_000 });
328 await warm.close();
329 break;
330 } catch (error) {
331 await warm.close();
332 if (attempt === 3) throw error;
333 }
334 }
335 });
336 afterAll(async () => {
337 await browser?.close();
338 await vite?.close();
339 void daemon?.stop(true);
340 });
341
342 /// How far the open has got: the rows the transcript holds, how much it can scroll, and the
343 /// height the loader's own threshold is measured in.
344 const progress = () => {
345 const scroller = document.querySelector(".acpmux-scroll");
346 if (!scroller) return { rows: 0, scrollable: 0, view: 0 };
347 return {
348 rows: scroller.querySelectorAll(".acpmux-row").length,
349 scrollable: scroller.scrollHeight - scroller.clientHeight,
350 view: scroller.clientHeight,
351 };
352 };
353
354 /// Waits until nothing the open is still doing can change what is measured below, by what has
355 /// happened and not by how long ago. Either of two things is enough:
356 ///
357 /// - acpmux has served the chat's oldest record, so nothing older is left to read; or
358 /// - the transcript holds six viewports of history, which is where the older-history loader
359 /// stops asking (node.scrollTop >= node.clientHeight * OLDER_AHEAD_VIEWPORTS, App.tsx). A view with that much
360 /// below it has been placed at its latest row and is pinned there as each page lands
361 /// (didOpenAtLatest, settleFollowBottom), so records arriving above the first row move the
362 /// rows above the reader and not the last one off the composer.
363 ///
364 /// This is the gate the time-based one has to replace. The dock and the scroller mount on the
365 /// App's first render, long before the attach reply, and the gap between a reply and the read it
366 /// starts is hundreds of milliseconds on a loaded runner and seconds on a throttled renderer —
367 /// so a chat held still for a few frames, or quiet for a moment, is just as likely to be a chat
368 /// caught mid-load, whose numbers are the bug's own (no rows, a band, nothing to scroll).
369 ///
370 /// False means the open never read through, which the test asserts and reports — but it measures
371 /// first, so the failure names the band with Leo's numbers and not just the wait. The bounds are
372 /// a deadline as well as a pass count, because one evaluate costs seconds on a throttled
373 /// renderer and 600 of those would outlive the file's own timeout.
374 const readThrough = async (page: Page) => {
375 const until = Date.now() + 25_000 + 20 * LATENCY_MS;
376 for (let pass = 0; pass < 600 && Date.now() < until; pass += 1) {
377 const now = (await page.evaluate((${progress.toString()})())) as ReturnType;
378 if (now.rows > 0 && (servedOldest || now.scrollable > 6 * now.view)) return true;
379 // The next look comes a frame later, once the page has drawn what it has.
380 await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve)));
381 }
382 return false;
383 };
384
385 /// What the reopened chat shows once it has settled: the room the last message leaves above the
386 /// composer, whether the view is at the bottom, and how much of the chat is in the transcript.
387 const settled = async () => {
388 const scroller = document.querySelector(".acpmux-scroll")!;
389 const dock = document.querySelector(".acpmux-dock")!;
390 const frame = () => window.cmuxPaintFrame();
391 const count = () => scroller.querySelectorAll(".acpmux-row").length;
392 // The chat has been read through before this runs (readThrough), so nothing in flight can
393 // still add a row. This only waits for the last page to be laid out: the extent, the row count
394 // and the offset stop changing for a few frames in a row.
395 let was = "";
396 for (let still = 0, passes = 0; still < 8; passes += 1) {
397 if (passes > 1800) throw new Error(the chat never settled: ${was});
398 await frame();
399 const now = ${scroller.scrollHeight}|${count()}|${scroller.scrollTop};
400 still = now === was ? still + 1 : 0;
401 was = now;
402 }
403 // The band is only the transcript's own to answer for while the composer floats over it
404 // (data-dock); on the hero, a handoff review, the diff or the inspector the stack is in flow
405 // and .acpmux-dock is display: contents, whose rect is all zeroes (bottomStack.css).
406 const mode = document.querySelector(".acpmux-main")?.dataset.dock;
407 if (mode !== "float") throw new Error(the stage is not the transcript: data-dock=${mode});
408 const rows = [...scroller.querySelectorAll(".acpmux-row")];
409 const last = rows.at(-1);
410 if (!last) throw new Error("the chat drew no row");

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "nl -ba files/webviews/test/wheel-glide-motion.test.ts | sed -n '1,320p'; printf '\nAPP WHEEL INSTALL CONTEXT\n'; rg -n "installWheelGlide|wheelGlide|onWheel|checkOlder" files/webviews/src/agent-session/acpmux/App.tsx files/webviews/test -g '.ts'" in /tmp/review-27311-ac90
succeeded in 0ms:
1 // l10n-allow-file: deterministic wheel and scroll-clock fixtures.
2 import { expect, test } from "bun:test";
3 import { installWheelGlide, isWheelNotch } from "../src/agent-session/acpmux/wheelGlide";
4 import { animateScroll, stopScroll } from "../src/agent-session/acpmux/threadMinimap/scrollMotion";
5 import { JUMP_CURVE } from "../src/agent-session/acpmux/threadMinimap/model";
6
7 function fixture() {
8 const saved = [globalThis.requestAnimationFrame, globalThis.cancelAnimationFrame] as const;
9 const pending = new Map<number, FrameRequestCallback>();
10 let handle = 0;
11 let now = Math.floor(performance.now());
12 const savedNow = performance.now;
13 performance.now = () => now;
14 globalThis.requestAnimationFrame = (run) => {
15 pending.set(++handle, run);
16 return handle;
17 };
18 globalThis.cancelAnimationFrame = (id) => void pending.delete(id);
19 let duration = "";
20 const page = Object.assign(new EventTarget(), {
21 getComputedStyle: () => ({ overflowY: "auto", overflowX: "auto", getPropertyValue: () => duration }),
22 });
23 const doc = Object.assign(new EventTarget(), { defaultView: page, hidden: false, documentElement: { dataset: {} } });
24 let round = Math.round;
25 class Scroller extends EventTarget {
26 /// The wheel listeners that may cancel the scroll: WebKit sends each wheel to the page first
27 /// while there is one.
28 cancelling = new Set();
29 override addEventListener(
30 type: string,
31 listener: EventListenerOrEventListenerObject | null,
32 options?: AddEventListenerOptions | boolean,
33 ) {
34 if (type === "wheel" && !(typeof options === "object" && options.passive)) this.cancelling.add(listener);
35 super.addEventListener(type, listener, options);
36 }
37 override removeEventListener(
38 type: string,
39 listener: EventListenerOrEventListenerObject | null,
40 options?: EventListenerOptions | boolean,
41 ) {
42 if (type === "wheel") this.cancelling.delete(listener);
43 super.removeEventListener(type, listener, options);
44 }
45 ownerDocument = doc;
46 clientHeight = 500;
47 scrollHeight = 2500;
48 private top = 1000;
49 getAttribute() {
50 return null;
51 }
52 get scrollTop() {
53 return this.top;
54 }
55 set scrollTop(value: number) {
56 this.top = Math.max(0, Math.min(this.scrollHeight - this.clientHeight, round(value)));
57 }
58 }
59 const box = new Scroller();
60 const node = box as unknown as HTMLElement;
61 const glide = installWheelGlide(node);
62 const wheel = (dy: number, overrides: Record<string, unknown> = {}) => {
63 const event = new Event("wheel", { cancelable: true });
64 Object.assign(event, { deltaY: dy, deltaX: 0, deltaMode: 1, composedPath: () => [node], ...overrides });
65 node.dispatchEvent(event);
66 return event.defaultPrevented;
67 };
68 return {
69 node,
70 cancelling: () => box.cancelling.size,
71 floorOffsets() {
72 round = Math.floor;
73 },
74 motion(value: string) {
75 duration = value;
76 },
77 grow(by: number) {
78 box.scrollHeight += by;
79 },
80 glide,
81 doc,
82 page,
83 wheel,
84 pending,
85 tick(ms = 20) {
86 now += ms;
87 const callbacks = [...pending.values()];
88 pending.clear();
89 for (const callback of callbacks) callback(now);
90 },
91 finish() {
92 this.tick(500);
93 },
94 restore() {
95 glide.remove();
96 stopScroll(node);
97 [globalThis.requestAnimationFrame, globalThis.cancelAnimationFrame] = saved;
98 performance.now = savedNow;
99 },
100 };
101 }
102
103 test("notch classification leaves ambiguous precise input native", () => {
104 expect(isWheelNotch({ deltaY: -100, deltaMode: 0, wheelDeltaY: 120 })).toBe(true);
105 expect(isWheelNotch({ deltaY: -100.006103515625, deltaMode: 0, wheelDeltaY: 300 })).toBe(true);
106 expect(isWheelNotch({ deltaY: -40, deltaMode: 0, wheelDeltaY: 120 })).toBe(false);
107 expect(isWheelNotch({ deltaY: -2.5, deltaMode: 0, wheelDeltaY: 7 })).toBe(false);
108 });
109
110 test("accumulated and equal reverse notches preserve their full distance", () => {
111 const f = fixture();
112 try {
113 expect(f.wheel(5)).toBe(true);
114 expect(f.wheel(-5)).toBe(true);
115 f.finish();
116 expect(f.node.scrollTop).toBe(1000);
117 expect(f.pending.size).toBe(0);
118 f.wheel(5);
119 f.tick();
120 f.wheel(5);
121 f.finish();
122 expect(f.node.scrollTop).toBe(1160);
123 } finally {
124 f.restore();
125 }
126 });
127
128 test("row-id anchor correction is retained while the notch adds only its own distance", () => {
129 const f = fixture();
130 try {
131 f.wheel(-10);
132 f.tick();
133 // The existing prepend anchor owns this write, not the animation.
134 f.grow(500);
135 f.node.scrollTop += 500;
136 f.finish();
137 expect(f.node.scrollTop).toBe(1340);
138 } finally {
139 f.restore();
140 }
141 });
142
143 test("a deliberate wheel to the bottom lands at the streamed end, including shrink", () => {
144 const f = fixture();
145 try {
146 f.node.scrollTop = 1950;
147 f.wheel(10);
148 f.tick();
149 f.grow(200);
150 f.finish();
151 expect(f.node.scrollTop).toBe(2200);
152 f.node.scrollTop = 2100;
153 f.wheel(10);
154 f.tick();
155 f.grow(-150);
156 f.finish();
157 expect(f.node.scrollTop).toBe(2050);
158 } finally {
159 f.restore();
160 }
161 });
162
163 test.each(["keydown", "pointerdown", "touchstart", "precise", "nested", "hidden", "pane", "switch", "remove"])(
164 "%s cancels pending motion and leaves no later write",
165 (reason) => {
166 const f = fixture();
167 try {
168 f.wheel(-5);
169 f.tick();
170 const stopped = f.node.scrollTop;
171 if (["keydown", "pointerdown", "touchstart"].includes(reason)) f.page.dispatchEvent(new Event(reason));
172 else if (reason === "precise") f.wheel(-40, { deltaMode: 0, wheelDeltaY: 120 });
173 else if (reason === "nested") {
174 const nested = Object.assign(new EventTarget(), {
175 ownerDocument: f.doc,
176 scrollHeight: 1000,
177 clientHeight: 100,
178 getAttribute: () => null,
179 });
180 f.wheel(-5, { composedPath: () => [nested, f.node] });
181 } else if (reason === "hidden") {
182 f.doc.hidden = true;
183 f.doc.dispatchEvent(new Event("visibilitychange"));
184 } else if (reason === "pane") Object.defineProperty(f.node, "clientHeight", { value: 0 });
185 else if (reason === "switch") f.glide.stop();
186 else f.glide.remove();
187 f.finish();
188 expect(f.node.scrollTop).toBe(stopped);
189 expect(f.pending.size).toBe(0);
190 } finally {
191 f.restore();
192 }
193 },
194 );
195
196 test("a jump replaces wheel motion on the same clock; old cleanup cannot stop the jump", () => {
197 const f = fixture();
198 try {
199 f.wheel(-5);
200 f.tick();
201 animateScroll(f.node, () => 1900, 180, JUMP_CURVE);
202 f.glide.stop();
203 f.finish();
204 expect(f.node.scrollTop).toBe(1900);
205 expect(f.pending.size).toBe(0);
206 } finally {
207 f.restore();
208 }
209 });
210
211 test("notches honor the host motion speed and a zero duration remains native", () => {
212 const f = fixture();
213 try {
214 f.motion("360ms");
215 f.wheel(-10);
216 f.tick(180);
217 expect(f.node.scrollTop).toBeGreaterThan(840);
218 expect(f.pending.size).toBe(1);
219 f.tick(180);
220 expect(f.node.scrollTop).toBe(840);
221 expect(f.pending.size).toBe(0);
222 f.motion("0s");
223 expect(f.wheel(-10)).toBe(false);
224 expect(f.pending.size).toBe(0);
225 } finally {
226 f.restore();
227 }
228 });
229
230 test.each(
231 [
232 [20, 20, 20, 20, 20, 20, 20, 20, 20],
233 [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 4],
234 [176.57, 4.43],
235 ].map((frames) => ({ frames })),
236 )("subpixel travel is monotonic at every frame cadence (%j)", ({ frames }) => {
237 const f = fixture();
238 try {
239 f.floorOffsets();
240 f.wheel(-100.006103515625, { deltaMode: 0, wheelDeltaY: 300 });
241 const offsets = [f.node.scrollTop];
242 for (const duration of frames) {
243 f.tick(duration);
244 offsets.push(f.node.scrollTop);
245 }
246 expect(offsets.slice(1).every((top, index) => top <= offsets[index]!)).toBe(true);
247 expect(f.node.scrollTop).toBe(900);
248 expect(f.pending.size).toBe(0);
249 } finally {
250 f.restore();
251 }
252 });
253
254 // A wheel listener that may cancel makes WebKit hold every wheel event for the page before it
255 // scrolls, so a trackpad's scroll stopped while the page was busy, as when an older page of history
256 // lands (Leo 2026-10-29: "why is it sometimes blocking me from scrolling up further?").
257 test("precise input scrolls with no cancelling wheel listener; the next notch arms the glide again", () => {
258 const f = fixture();
259 try {
260 // A fresh pane's first notch glides.
261 expect(f.wheel(-5)).toBe(true);
262 f.finish();
263 // A trackpad: no wheel listener may cancel once it is seen, so WebKit scrolls on its own.
264 expect(f.wheel(-40, { deltaMode: 0, wheelDeltaY: 120 })).toBe(false);
265 expect(f.cancelling()).toBe(0);
266 expect(f.wheel(-12, { deltaMode: 0, wheelDeltaY: 36 })).toBe(false);
267 expect(f.cancelling()).toBe(0);
268 // A mouse again: its first notch scrolls natively and arms the glide; the next one glides.
269 expect(f.wheel(-5)).toBe(false);
270 expect(f.cancelling()).toBe(1);
271 expect(f.wheel(-5)).toBe(true);
272 f.finish();
273 f.glide.remove();
274 expect(f.cancelling()).toBe(0);
275 } finally {
276 f.restore();
277 }
278 });

APP WHEEL INSTALL CONTEXT
files/webviews/test/wheel-glide-motion.test.ts:3:import { installWheelGlide, isWheelNotch } from "../src/agent-session/acpmux/wheelGlide";
files/webviews/test/wheel-glide-motion.test.ts:61: const glide = installWheelGlide(node);
files/webviews/test/transcript-older-scroll.test.ts:90: await page.evaluate(() => (document.documentElement.dataset.wheelGlide = "off"));
files/webviews/test/browser/transcript-older-scroll.harness.tsx:1:// The chat's transcript with older history paging in as the reader scrolls up (App.tsx checkOlder),
files/webviews/src/agent-session/acpmux/App.tsx:323:import { installWheelGlide } from "./wheelGlide";
files/webviews/src/agent-session/acpmux/App.tsx:2219: const wheelGlide = useRef<ReturnType>(undefined);
files/webviews/src/agent-session/acpmux/App.tsx:2229: wheelGlide.current?.stop();
files/webviews/src/agent-session/acpmux/App.tsx:3149: wheelGlide.current?.stop();
files/webviews/src/agent-session/acpmux/App.tsx:3664: const checkOlderNow = useRef<() => void>(() => {});
files/webviews/src/agent-session/acpmux/App.tsx:3672: checkOlderNow.current();
files/webviews/src/agent-session/acpmux/App.tsx:3912: wheelGlide.current = node ? installWheelGlide(node) : undefined;
files/webviews/src/agent-session/acpmux/App.tsx:3914: wheelGlide.current?.remove();
files/webviews/src/agent-session/acpmux/App.tsx:3915: wheelGlide.current = undefined;
files/webviews/src/agent-session/acpmux/App.tsx:3967: const checkOlder = () => {
files/webviews/src/agent-session/acpmux/App.tsx:3996: checkOlderNow.current();
files/webviews/src/agent-session/acpmux/App.tsx:4002: checkOlderNow.current = checkOlder;
files/webviews/src/agent-session/acpmux/App.tsx:4005: checkOlderNow.current();
files/webviews/src/agent-session/acpmux/App.tsx:4010: const onWheel = (event: React.WheelEvent) => {
files/webviews/src/agent-session/acpmux/App.tsx:4013: checkOlderNow.current();
files/webviews/src/agent-session/acpmux/App.tsx:4040: onWheel={onWheel}

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '2140,2320p'; nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '3000,3200p'; nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '3500,3895p'" in /tmp/review-27311-ac90
succeeded in 0ms:
2140 const setScrollAt = (top: number, delta = top - scrollRef.current.top) => {
2141 const next = { top, delta };
2142 scrollRef.current = next;
2143 setScroll(next);
2144 };
2145 /// The view follows the latest row (scrolledTo.atLatest, as of the last scroll). Jump to latest shows
2146 /// when it does not and the latest message is more than JUMP_LATEST_DISTANCE below, read each render,
2147 /// so a reply growing below or a session switch updates it without a scroll.
2148 const [following, setFollowing] = useState(true);
2149 // Without the folder's GitHub remote, a bare #N in a row means the repository the transcript
2150 // mentioned last (conversation/transcriptRepositories.ts); each row's text is read once per change.
2151 const repositoryCache = useRef(new Map());
2152 const rowRepositories = useMemo(
2153 () => (githubRepository ? undefined : transcriptRepositories(rows, fallbackRepository, repositoryCache.current)),
2154 [githubRepository, fallbackRepository, rows],
2155 );
2156 // Which repository each row's bare #N names, by the transcript's evidence
2157 // (conversation/bareReferences.ts): one scope object per turn, kept while nothing new is named;
2158 // a streaming reply reads only its own row again.
2159 const referenceCache = useRef(newReferenceScopeCache());
2160 const scopeAt = useMemo(() => {
2161 const cache = referenceCache.current;
2162 const scopeOf = referenceScopes(sourceRows ?? rows, cache);
2163 return (index: number) => scopeOf(turnOpener(cache, rows, index));
2164 }, [rows, sourceRows]);
2165 // scroll events also fire for layout-driven writes to scrollTop. Keep the user's intent in a
2166 // ref so those events cannot turn a pinned transcript into a one-frame jump when the composer
2167 // stack changes height.
2168 const followingRef = useRef(true);
2169 const follow = (next: boolean) => {
2170 followingRef.current = next;
2171 onFollowingChange?.(next);
2172 setFollowing(next);
2173 };
2174 const followRef = useRef(follow);
2175 followRef.current = follow;
2176 const transcriptMounted = useRef(true);
2177 const scrollPacer = useRef<ReturnType | undefined>(undefined);
2178 scrollPacer.current ??= createScrollPacer((frame: ScrollFrame) => {
2179 if (!transcriptMounted.current) return;
2180 // A rail jump, a wheel glide, a layout correction or any other frame callback may move the live
2181 // scroller after an event queued this frame. That event is stale: its state must not restore the
2182 // old offset, so the commit renders the live offset instead. Dropping the commit (as it once did)
2183 // left the rows mounted where the scroll began for as long as a frame-driven scroll kept moving,
2184 // so a jump or a fling painted blank frames all the way (transcript-fling, cached rows).
2185 const live = ref.current?.scrollTop;
2186 if (live !== undefined && Math.abs(live - frame.top) > 0.5)
2187 scrollRef.current = { top: live, delta: live - scrollRef.current.top || scrollRef.current.delta };
2188 // One commit a frame, flushed in the pacer's own frame callback: React would otherwise render an
2189 // update made here in a later task, after the frame paints with the rows of the last offset.
2190 acpmuxPerf.timed("flush.scroll", () =>
2191 flushSync(() => {
2192 setScroll(scrollRef.current);
2193 setFollowing(followingRef.current);
2194 }),
2195 );
2196 });
2197 const queueScroll = (top: number, atLatest: boolean) => {
2198 // Keep the intent ref synchronous so a second scroll event before the next frame sees the
2199 // reader's latest state; React work itself is coalesced by scrollPacer.
2200 followingRef.current = atLatest;
2201 onFollowingChange?.(atLatest);
2202 // The pacer may already have committed this offset (a frame-driven scroll moves it before its
2203 // event comes): an event that brings no new offset keeps the direction the scroll has. Its delta
2204 // of 0 left the next render no lead, and a fling painted blank frames (transcript-fling on the
2205 // CI runner, mounted rows a step behind the view).
2206 scrollRef.current = { top, delta: top - scrollRef.current.top || scrollRef.current.delta };
2207 scrollPacer.current!.enqueue({ top, atLatest });
2208 };
2209 useEffect(() => {
2210 transcriptMounted.current = true;
2211 return () => {
2212 transcriptMounted.current = false;
2213 scrollPacer.current?.cancel();
2214 };
2215 }, []);
2216 /// Where Jump to latest's glide is headed while it is in flight: its steps are the view following,
2217 /// not the reader's scroll, and nothing snaps it to the end before it arrives.
2218 const jumpTarget = useRef<number | undefined>(undefined);
2219 const wheelGlide = useRef<ReturnType>(undefined);
2220 // The last transcript box on this page until this one is measured (before its first paint, below).
2221 const [height, setHeight] = useState(() => lastTranscriptBox.height);
2222 /// The scrollbar's lane (scrollers.ts SHOWN_SCROLLER_ATTRIBUTE: always there, its thumb drawn while
2223 /// the chat overflows). The rows lay out across it, against the scroller's full width as the
2224 /// composer does, so the column matches the composer's and nothing reflows as a chat starts to scroll.
2225 const [lane, setLane] = useState(0);
2226 const ref = useRef(null);
2227 const stopJump = useCallback(() => {
2228 // Find, reveal and an asynchronous send own their new placement, even without a jump in flight.
2229 wheelGlide.current?.stop();
2230 const active = jumpTarget.current !== undefined;
2231 jumpTarget.current = undefined;
2232 if (active && ref.current) stopScroll(ref.current);
2233 }, []);
2234 // The shared animation owns one RAF and its input listeners. Leaving this chat or unmounting
2235 // cancels that ride before the next session can reuse this scroller.
2236 useLayoutEffect(() => stopJump, [sessionId, stopJump]);
2237 /// The bottom stack's height over the view's foot: room under the last row, out of the view.
2238 const [dockSpace, setDockSpace] = useState(0);
2239 const dockSpaceRef = useRef(0);
2240 dockSpaceRef.current = dockSpace;
2241 // Full stack height is distinct from the overlap room: a tall dock can extend beyond the
2242 // viewport, while an after-latest tray still needs the whole stack reserved in stage flow.
2243 const [dockHeight, setDockHeight] = useState(0);
2244 const dockHeightRef = useRef(0);
2245 dockHeightRef.current = dockHeight;
2246 /// The composer card's height, in that stack: what grows a line at a time (Shift-Return, a wrap).
2247 const [dockCard, setDockCard] = useState(0);
2248 const dockCardRef = useRef(0);
2249 dockCardRef.current = dockCard;
2250 /// A queued prompt left the bottom stack this frame: a sent one is about to come in as the latest
2251 /// row; a withdrawn one (deleted from the queue) never does.
2252 const queueLeft = useRef(false);
2253 const queuedIds = useRef(new Set());
2254 /// The prompts whose cards left, which the room under the last row is kept for (floor).
2255 const heldFor = useRef(new Set());
2256 const withdrawnSet = useMemo(() => new Set(queueWithdrawn), [queueWithdrawn]);
2257 const withdrawn = useRef(withdrawnSet);
2258 withdrawn.current = withdrawnSet;
2259 const dockRef = useRef(dock);
2260 dockRef.current = dock;
2261 const queueSession = useRef(sessionId);
2262 queueSession.current = sessionId;
2263 /// The chat and the stack queuedIds were noted in.
2264 const notedIn = useRef<{ sessionId?: string; dock?: HTMLElement | null }>({});
2265 /// Notes the stack's queued prompts; called first by each size observer (the stack's, and the
2266 /// view's, for a stack laid out under the transcript), so the render a size change makes knows,
2267 /// and by each change of the queue (below). A prompt the queue's fold hides (ComposerQueue
2268 /// data-folded-ids) is still queued: closing the fold is not its card leaving, and no room is
2269 /// kept under the last row for a prompt that stays queued (Leo 2026-10-21: the queue closed left a
2270 /// hole over the stack, then the rows dropped).
2271 const noteQueue = useCallback(() => {
2272 const dock = dockRef.current;
2273 const now = new Set([
2274 ...[...(dock?.querySelectorAll(".acpmux-queued") ?? [])].map((card) => card.dataset.promptId ?? ""),
2275 ...[...(dock?.querySelectorAll("[data-folded-ids]") ?? [])].flatMap((list) =>
2276 (list.dataset.foldedIds ?? "").split(" "),
2277 ),
2278 ...(queueIds ?? []),
2279 ]);
2280 // Another chat's queue, or another stack, starts over: the last one's prompts never left this
2281 // one, and no room is held for them (#24182: switched to a chat with its own queue, the last
2282 // chat's prompts read as cards that left, and a prompt withdrawn later kept room as a sent one).
2283 if (notedIn.current.sessionId !== queueSession.current || notedIn.current.dock !== dock) {
2284 notedIn.current = { sessionId: queueSession.current, dock };
2285 queuedIds.current = now;
2286 heldFor.current.clear();
2287 queueLeft.current = false;
2288 return;
2289 }
2290 const left = [...queuedIds.current].filter((id) => !now.has(id));
2291 queuedIds.current = now;
2292 if (!left.length) return;
2293 for (const id of left) heldFor.current.add(id);
2294 if (queueLeft.current) return;
2295 queueLeft.current = true;
2296 // Only the size change of this frame is the card leaving.
2297 requestAnimationFrame(() => requestAnimationFrame(() => (queueLeft.current = false)));
2298 }, [queueIds]);
2299 useLayoutEffect(() => {
2300 if (!dock) {
2301 setDockSpace(0);
2302 setDockHeight(0);
2303 setDockCard(0);
2304 return;
2305 }
2306 // Before the frame paints, as the viewport's own size is: a composer that grew a line, or a
2307 // queue that appeared, never draws a frame with the latest row under it. The room is how far
2308 // the stack reaches over the view: none for a stack in the flow under it, or with no box
2309 // (display: contents).
2310 const over = () => {
2311 const stack = dock.getBoundingClientRect();
2312 const view = ref.current?.getBoundingClientRect();
2313 return stack.height > 0 && view ? Math.max(0, Math.min(stack.height, view.bottom - stack.top)) : 0;
2314 };
2315 const height = () => Math.max(0, dock.getBoundingClientRect().height);
2316 // The composer card's height, while the stack reaches over the view.
2317 const card = (space: number) =>
2318 space > 0 ? (dock.querySelector(".acpmux-composer-box")?.getBoundingClientRect().height ?? 0) : 0;
2319 const observer = new ResizeObserver(() => {
2320 noteQueue();
3000 }
3001 // A session can reuse row ids. Until the reset effect commits, render the new session as a
3002 // fresh transcript so a stale focus or range never flashes on its first paint.
3003 const effectiveActiveRowId = sessionChanged ? rows[0]?.id : activeRowId;
3004 const effectiveTranscriptRange = sessionChanged ? undefined : transcriptRange;
3005 const selectedBounds = useMemo(() => {
3006 if (!effectiveTranscriptRange) return undefined;
3007 const anchor = rows.findIndex((row) => row.id === effectiveTranscriptRange.anchorId);
3008 const focus = rows.findIndex((row) => row.id === effectiveTranscriptRange.focusId);
3009 if (anchor < 0 || focus < 0) return undefined;
3010 return { first: Math.min(anchor, focus), last: Math.max(anchor, focus) };
3011 }, [rows, effectiveTranscriptRange]);
3012 const selectedRows = useMemo(() => {
3013 if (!selectedBounds) return new Set();
3014 const selected = new Set();
3015 for (let index = selectedBounds.first; index <= selectedBounds.last; index += 1) selected.add(index);
3016 return selected;
3017 }, [selectedBounds]);
3018 const selectedRowIds = useMemo(
3019 () => (selectedBounds ? rows.slice(selectedBounds.first, selectedBounds.last + 1).map((row) => row.id) : []),
3020 [rows, selectedBounds],
3021 );
3022 const focusRow = useCallback(
3023 (index: number) => {
3024 if (rows.length === 0) return;
3025 const nextIndex = Math.max(0, Math.min(rows.length - 1, index));
3026 const id = rows[nextIndex]!.id;
3027 setActiveRowId(id);
3028 pendingFocus.current = id;
3029 const node = ref.current;
3030 if (!node) return;
3031 const mounted = [...node.querySelectorAll(".acpmux-row")].find((row) => row.dataset.rowId === id);
3032 if (mounted) {
3033 mounted.focus();
3034 pendingFocus.current = undefined;
3035 return;
3036 }
3037 unlift();
3038 const top = layout.tops[nextIndex] ?? 0;
3039 const maximum = Math.max(0, layout.totalHeight - node.clientHeight);
3040 const nextTop = Math.max(0, Math.min(maximum, top));
3041 const delta = nextTop - node.scrollTop;
3042 node.scrollTop = nextTop;
3043 scrollPacer.current?.cancel();
3044 setScrollAt(nextTop, delta || nextTop - scrollRef.current.top);
3045 },
3046 [layout, rows, unlift],
3047 );
3048 useLayoutEffect(() => {
3049 const id = pendingFocus.current;
3050 if (!id) return;
3051 const node = ref.current;
3052 const target = [...(node?.querySelectorAll(".acpmux-row") ?? [])].find(
3053 (row) => row.dataset.rowId === id,
3054 );
3055 if (target) {
3056 target.focus();
3057 pendingFocus.current = undefined;
3058 }
3059 }, [range, effectiveActiveRowId]);
3060 const copyRows = useCallback(
3061 async (rowIds: string[]) => {
3062 const text = transcriptRangeText(rows, rowIds);
3063 if (text && !(await copyTextResult(text))) throw new Error("copy.failed");
3064 },
3065 [rows],
3066 );
3067 const rowKeyDown = useCallback(
3068 (index: number, event: React.KeyboardEvent) => {
3069 // Buttons and links own their keyboard behavior. A focused row itself owns only the
3070 // navigation and copy keys below, so selecting prose never toggles a disclosure by accident.
3071 if (event.target !== event.currentTarget) return;
3072 const move = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
3073 const jump = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1 : undefined;
3074 if (move || jump !== undefined) {
3075 event.preventDefault();
3076 const nextIndex = jump ?? index + move;
3077 const clamped = Math.max(0, Math.min(rows.length - 1, nextIndex));
3078 const nextId = rows[clamped]?.id;
3079 if (!nextId) return;
3080 setTranscriptRange((current) =>
3081 event.shiftKey ? { anchorId: current?.anchorId ?? rows[index]!.id, focusId: nextId } : undefined,
3082 );
3083 focusRow(clamped);
3084 return;
3085 }
3086 if (event.key === "Escape") {
3087 if (effectiveTranscriptRange) {
3088 event.preventDefault();
3089 setTranscriptRange(undefined);
3090 }
3091 return;
3092 }
3093 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "c") {
3094 const native = window.getSelection?.();
3095 if (native && !native.isCollapsed) return;
3096 event.preventDefault();
3097 const rowIds = selectedRowIds.length > 0 ? selectedRowIds : [rows[index]!.id];
3098 void copyRows(rowIds).catch(() => undefined);
3099 }
3100 },
3101 [copyRows, effectiveTranscriptRange, focusRow, rows, selectedRowIds],
3102 );
3103 const rowFocus = useCallback((id: string) => setActiveRowId(id), []);
3104 // Keep the keyboard callback stable across transcript snapshots.
3105 const rowKeyDownRef = useRef(rowKeyDown);
3106 rowKeyDownRef.current = rowKeyDown;
3107 const handleRowKeyDown = useCallback(
3108 (index: number, event: React.KeyboardEvent) => rowKeyDownRef.current(index, event),
3109 [],
3110 );
3111 // Avoid allocating a new slice on every scroll/follow commit. Long transcripts can dispatch
3112 // several state commits per frame while the reader is moving; the mounted range is already
3113 // bounded, so a direct scan keeps the check allocation-free.
3114 let activeVisible = false;
3115 if (effectiveActiveRowId) {
3116 for (let index = range.first; index < range.last; index += 1) {
3117 if (rows[index]?.id === effectiveActiveRowId) {
3118 activeVisible = true;
3119 break;
3120 }
3121 }
3122 }
3123 const tabStopId = activeVisible ? effectiveActiveRowId : rows[range.first]?.id;
3124 useLayoutEffect(() => {
3125 const spans = mountedRanges.map(({ first, last }): MountedSpan => [
3126 layout.tops[first],
3127 layout.tops[last - 1] + layout.heights[last - 1],
3128 ]);
3129 acpmuxPerf.mounted = spans;
3130 // A memo hit spent no time in geometry this render.
3131 const freshLayout = reportedLayout.current !== measured;
3132 reportedLayout.current = measured;
3133 const freshEstimate = reportedEstimate.current !== estimated;
3134 reportedEstimate.current = estimated;
3135 const layoutMs = (freshLayout ? measured.ms : 0) + (freshEstimate ? estimated.ms : 0);
3136 if (acpmuxPerf.enabled && freshLayout) acpmuxPerf.addLayout(layoutMs);
3137 if (acpmuxPerf.enabled && renderStart > 0) {
3138 const now = performance.now();
3139 acpmuxPerf.commit(now - renderStart, layoutMs, spans, now);
3140 }
3141 });
3142 useLayoutEffect(() => {
3143 const switched = previousSession.current !== sessionId;
3144 if (switched) {
3145 // A single VirtualTranscript is reused while the selected chat changes. Discard every
3146 // scroll anchor from the old session before the placement pass chooses the new session's
3147 // openAt position or latest row.
3148 previousSession.current = sessionId;
3149 wheelGlide.current?.stop();
3150 didOpenAtLatest.current = false;
3151 // Another chat's count (the subagent view takes none) is not a send in this one.
3152 seenSends.current = sends;
3153 sentBeforeOpen.current = false;
3154 endSeek();
3155 setSeeking(false);
3156 setSeekExpired(false);
3157 previousLayout.current = null;
3158 previousRows.current = rows;
3159 scrolledTo.current = { top: 0, atLatest: false };
3160 seenAnchor.current = undefined;
3161 scrollPacer.current?.cancel();
3162 stopJump();
3163 setScrollAt(0, 0);
3164 follow(true);
3165 }
3166 const old = switched ? null : previousLayout.current;
3167 const oldRows = previousRows.current;
3168 const node = ref.current;
3169 // The view stopped following with its rows drawn up by the composer's growth (lift): that
3170 // growth is room now (heldRoom), and the offset moves by it in this same frame, so nothing the
3171 // reader sees moves.
3172 if (old && node && !following && appliedLift.current) {
3173 node.scrollTop -= appliedLift.current;
3174 scrolledTo.current = { ...scrolledTo.current, top: scrolledTo.current.top - appliedLift.current };
3175 // The rows mounted for this frame read the offset too, before its scroll event comes.
3176 const moved = node.scrollTop;
3177 scrollPacer.current?.cancel();
3178 setScrollAt(moved);
3179 }
3180 // A disclosure the reader clicked stays where they clicked it while what it opens lays out: this
3181 // pass neither follows the latest row nor keeps the top row, it puts the clicked line back.
3182 const heldFrom = node?.scrollTop ?? 0;
3183 const folding = Boolean(old && node && foldAnchor.current && keepFold(foldAnchor.current, node));
3184 if (!folding) foldAnchor.current = undefined;
3185 if (folding && node && Math.abs(node.scrollTop - heldFrom) >= 0.5) {
3186 // The rows mounted for this frame read the held offset too, before its scroll event comes.
3187 const moved = node.scrollTop;
3188 scrollPacer.current?.cancel();
3189 setScrollAt(moved);
3190 }
3191 if (folding && node) {
3192 // What opened pushed the latest row out of view: the view is the reader's now, not following.
3193 if (followingRef.current && node.scrollTop < layout.totalHeight - node.clientHeight - FOLLOW_BOTTOM_DEADBAND_PX)
3194 follow(false);
3195 } else if (old && node) {
3196 // Content that shrank under the viewport has already clamped the live offset to
3197 // the new end; the offset recorded before this commit is where the reader was.
3198 // A clamp lands exactly on the scroller's own end, which rounds the layout's
3199 // fractional height, so compare with that rather than allow for the rounding.
3200 // A viewport that grew clamps at the end the content had then: the room a queued prompt's
3500 const node = ref.current;
3501 if (!node) return;
3502 if (!findQuery) return clearFindHighlights();
3503 const current = paintFindHighlights(
3504 node,
3505 findQuery,
3506 findTargetRowId !== undefined && findTargetOccurrence !== undefined && drawnId !== undefined
3507 ? { rowId: drawnId, card: drawnCard, occurrence: findTargetOccurrence, member: findMember }
3508 : undefined,
3509 );
3510 if (!current || revealing.current !== targetKey) return;
3511 // A match in a closed group is drawn before the group opens (with no size), and it moves as the
3512 // opened parts and the rows above it measure, so it is kept in view for a moment once drawn.
3513 if (!revealWindow.current) {
3514 const opened = (revealWindow.current = ++revealWindows.current);
3515 const start = performance.now();
3516 let frames = 0;
3517 let still = 0;
3518 let placed: number | undefined;
3519 const tick = () => {
3520 if (revealWindow.current !== opened) return;
3521 // The reader's own scroll, click or key ends the hold: the match is theirs to leave.
3522 if (readerAt.current > start) {
3523 revealing.current = undefined;
3524 return;
3525 }
3526 // Where the match sits in the transcript's content: it moves while what is above it measures,
3527 // and a move with no render (a block that grew) is brought back here, not only on a commit.
3528 const range = findActiveRange();
3529 if (range && range.getBoundingClientRect().height > 0) {
3530 scrollRangeInBoxes(node, range);
3531 const before = node.scrollTop;
3532 scrollRangeIn(node, range, dockSpaceRef.current);
3533 if (node.scrollTop !== before) findMoved(node);
3534 }
3535 const at = findMatchOffset(node);
3536 still = at !== undefined && placed !== undefined && Math.abs(at - placed) < 0.5 ? still + 1 : 0;
3537 placed = at;
3538 const elapsed = performance.now() - start;
3539 const settled = frames >= FIND_REVEAL_FRAMES && elapsed >= FIND_REVEAL_MS && still >= FIND_REVEAL_STILL_FRAMES;
3540 if (settled || ++frames >= FIND_REVEAL_MAX_FRAMES) revealing.current = undefined;
3541 else requestAnimationFrame(tick);
3542 };
3543 requestAnimationFrame(tick);
3544 }
3545 if (current.getBoundingClientRect().height === 0) return;
3546 // Inner scrollers first (a command's output scrolls on its own), then the transcript.
3547 scrollRangeInBoxes(node, current);
3548 const before = node.scrollTop;
3549 scrollRangeIn(node, current, dockSpaceRef.current);
3550 if (node.scrollTop !== before) findMoved(node);
3551 // Rows are immutable snapshots, so their identity changes when streamed text or a revealed
3552 // fold changes. The mounted-range signature catches virtualization entering/leaving rows.
3553 }, [
3554 drawnCard,
3555 drawnId,
3556 findMember,
3557 findQuery,
3558 findTargetOccurrence,
3559 findMoved,
3560 findTargetRowId,
3561 mountedRangeKey,
3562 revealed,
3563 rows,
3564 targetKey,
3565 ]);
3566 useEffect(() => clearFindHighlights, []);
3567 // Where the view rests, once a scroll settles; the latest message is null.
3568 const reportedAnchor = useRef<string | undefined>(undefined);
3569 const anchorTimer = useRef<ReturnType | undefined>(undefined);
3570 const latestLayout = useRef({ layout, rows });
3571 latestLayout.current = { layout, rows };
3572 useEffect(() => () => clearTimeout(anchorTimer.current), []);
3573 // A report that came due while the pane was hidden, sent once it has a viewport again.
3574 const anchorDeferred = useRef(false);
3575 useEffect(() => {
3576 clearTimeout(anchorTimer.current);
3577 anchorTimer.current = undefined;
3578 anchorDeferred.current = false;
3579 reportedAnchor.current = undefined;
3580 }, [sessionId]);
3581 const onAnchorRef = useRef(onAnchor);
3582 onAnchorRef.current = onAnchor;
3583 // WEB-SWAP: the transcript reopens at the place it reported (chat.scrollAnchor), so a web
3584 // update's reload waits only for a report still due (pages/shared/webSwap.ts). A hidden pane
3585 // reports where the reader last saw it (below), so it never holds the swap.
3586 useEffect(
3587 () =>
3588 holdWebSwap(() => (onAnchorRef.current && anchorTimer.current !== undefined ? "transcript.place-unsaved" : null)),
3589 [],
3590 );
3591 const reportAnchor = () => {
3592 anchorTimer.current = undefined;
3593 const node = ref.current;
3594 // Before the chat opens its offset is not the reader's: the kept place stays.
3595 if (!node || !onAnchor || !didOpenAtLatest.current) return;
3596 // A hidden pane (Home shown in its place) has no viewport: nothing it reads is where the reader is.
3597 anchorDeferred.current = node.clientHeight === 0;
3598 // Meanwhile it keeps where the reader last saw it (as pane.wakeState does); the report comes
3599 // again once it shows.
3600 if (anchorDeferred.current && seenAnchor.current === undefined) return;
3601 const { layout: placed, rows: shown } = latestLayout.current;
3602 const index = visibleLayoutRange(placed, node.scrollTop, 0, 0).first;
3603 const row = shown[index];
3604 const anchor: ScrollAnchor | null = anchorDeferred.current
3605 ? (seenAnchor.current ?? null)
3606 : scrolledTo.current.atLatest || !row
3607 ? null
3608 : { rowId: row.id, offset: node.scrollTop - placed.tops[index] };
3609 const key = JSON.stringify(anchor);
3610 if (key === reportedAnchor.current) return;
3611 reportedAnchor.current = key;
3612 onAnchor(anchor);
3613 };
3614 useEffect(() => {
3615 if (height > 0 && anchorDeferred.current) reportAnchor();
3616 });
3617 // A page about to let go (pane.wakeState) is hidden: it has no viewport, so its place is where
3618 // the reader last saw it.
3619 useEffect(() => {
3620 if (!anchorNow) return;
3621 anchorNow.current = () => seenAnchor.current;
3622 return () => {
3623 anchorNow.current = undefined;
3624 };
3625 }, [anchorNow]);
3626 // The reader sent a prompt: the view goes to the latest message before the frame paints, even
3627 // scrolled up, and follows the prompt's row and its reply in (Leo 2026-10-21). Only a scroll up
3628 // by the reader lets go of it again.
3629 useLayoutEffect(() => {
3630 if (sends === seenSends.current) return;
3631 seenSends.current = sends;
3632 const node = ref.current;
3633 if (!node) return;
3634 if (!didOpenAtLatest.current) {
3635 // The chat has not opened yet: it stops paging back to the reader's old place and opens at
3636 // the latest message (the placement pass, which seekExpired runs again).
3637 sentBeforeOpen.current = true;
3638 setSeekExpired(true);
3639 return;
3640 }
3641 revealing.current = undefined;
3642 revealWindow.current = 0;
3643 foldAnchor.current = undefined;
3644 stopJump();
3645 // The room the stack grew while scrolled up is room under the last row again (room).
3646 heldRoom.current = dockSpaceRef.current;
3647 node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight);
3648 const top = node.scrollTop;
3649 scrolledTo.current = { top, atLatest: true };
3650 seenAnchor.current = null;
3651 followTrace.set(top);
3652 scrollPacer.current?.cancel();
3653 setScrollAt(top);
3654 // The commit this asks for places the rows with the stack's room and settles at that end.
3655 follow(true);
3656 // The kept place is the latest message again: a relaunch opens there.
3657 if (onAnchor) {
3658 clearTimeout(anchorTimer.current);
3659 anchorTimer.current = setTimeout(reportAnchor, SCROLL_ANCHOR_SETTLE_MS);
3660 }
3661 // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new send moves the view
3662 }, [sends]);
3663 /// The older-history check (below), which every scroll event runs too.
3664 const checkOlderNow = useRef<() => void>(() => {});
3665 const onScroll = (event: React.UIEvent) => {
3666 pacing.scrolled();
3667 // Before the chat opens its offset moves only as pages land unseen: none of it is the reader's.
3668 if (!didOpenAtLatest.current) return;
3669 // A hidden scroller has no height, so every offset would read as scrolled up (cx-t0zqt): the view
3670 // keeps following until the reader scrolls a visible one.
3671 if (event.currentTarget.clientHeight === 0) return;
3672 checkOlderNow.current();
3673 const node = event.currentTarget;
3674 const next = node.scrollTop;
3675 // A step of Jump to latest's glide: the view is following on its way to the end, not snapped
3676 // there (a first step inside the deadband read as layout work), and not stopped (a longer one
3677 // read as the reader's scroll up).
3678 if (jumpTarget.current !== undefined) {
3679 scrolledTo.current = { top: next, atLatest: true };
3680 seenAnchor.current = null;
3681 followTrace.set(next);
3682 queueScroll(next, true);
3683 // The kept place is the latest message again (a reopened chat opens there, not where the
3684 // reader was before the jump).
3685 if (onAnchor) {
3686 clearTimeout(anchorTimer.current);
3687 anchorTimer.current = setTimeout(reportAnchor, SCROLL_ANCHOR_SETTLE_MS);
3688 }
3689 return;
3690 }
3691 const programmatic = performance.now() - readerAt.current > READER_SCROLL_MS;
3692 // ResizeObserver/layout work can emit a scroll event after the stack below the transcript
3693 // changes. While the reader is following, keep the viewport at the new end and do not let
3694 // that synthetic event clear the follow state. A real wheel/touch/key event marks itself first.
3695 // A layout-driven scroll keeps the previous offset; a reader scroll changes it. The latter is
3696 // also what the jsdom behavior tests dispatch without a wheel event, so do not classify every
3697 // programmatic-looking event as layout work.
3698 // A held disclosure (foldAnchor) keeps the view where it is, not at the latest row.
3699 const layoutDriven =
3700 followingRef.current &&
3701 programmatic &&
3702 !foldAnchor.current &&
3703 Math.abs(next - scrolledTo.current.top) <= FOLLOW_BOTTOM_DEADBAND_PX;
3704 if (layoutDriven) {
3705 const latest = Math.max(0, layout.totalHeight - node.clientHeight);
3706 const settled = settleFollowBottom(node, latest);
3707 const resting = settled ? latest : next;
3708 scrolledTo.current = { top: resting, atLatest: true };
3709 followTrace.set(resting);
3710 queueScroll(resting, true);
3711 return;
3712 }
3713 const was = scrolledTo.current;
3714 const measured = scrollPosition(node, layout.totalHeight);
3715 // A recent upward input is authoritative even when it moved only a pixel or two. The
3716 // deadband absorbs layout rounding, but must not turn a real trackpad step into bottom follow.
3717 const movedUp = next < was.top - 0.5;
3718 const recentUp = !programmatic && movedUp;
3719 const readerReachedBottom = !programmatic && next > was.top + 0.5 && measured.atLatest;
3720 scrolledTo.current = {
3721 top: next,
3722 atLatest: recentUp || (movedUp && !measured.atLatest) ? false : followingRef.current || readerReachedBottom,
3723 };
3724 const latest = Math.max(0, layout.totalHeight - node.clientHeight);
3725 if (was.atLatest && !scrolledTo.current.atLatest)
3726 followTrace.stopped({
3727 top: next,
3728 previous: was.top,
3729 latest,
3730 scrollHeight: node.scrollHeight,
3731 clientHeight: node.clientHeight,
3732 });
3733 followTrace.update(node, layout.totalHeight, scrolledTo.current.atLatest);
3734 seenAnchor.current = restingAnchor(next, scrolledTo.current.atLatest, layout, rows);
3735 queueScroll(next, scrolledTo.current.atLatest);
3736 if (onAnchor) {
3737 clearTimeout(anchorTimer.current);
3738 anchorTimer.current = setTimeout(reportAnchor, SCROLL_ANCHOR_SETTLE_MS);
3739 }
3740 };
3741 // Jump to latest (the button, ChatGPT and Codex style): a smooth scroll to the latest message, then a
3742 // landing on it, so a reply that grew meanwhile is followed again.
3743 const jumpToLatest = (focused: boolean) => {
3744 const node = ref.current;
3745 if (!node) return;
3746 // Jump to latest is the reader leaving the Find match: end its hold, or the next tick pulls the
3747 // view back to the match and stops following again.
3748 revealing.current = undefined;
3749 revealWindow.current = 0;
3750 // The room the stack grew while scrolled up comes first, out of view, so following again moves
3751 // nothing and the glide runs to the end above the card instead of snapping there.
3752 takeRoom();
3753 follow(true);
3754 if (focused) focusComposer?.();
3755 // cmux's animations off (ui.animations) or the system's Reduce Motion: it lands in one frame, and
3756 // with no glide in flight nothing aims one again at a moved end.
3757 const reduce = isAgentMotionOff() || globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
3758 // The layout the room just taken committed (flushSync above), not the one this render closed over.
3759 const target = Math.max(0, latestLayout.current.layout.totalHeight - node.clientHeight);
3760 stopJump();
3761 // Use the rail's shared curve and distance duration, scaled by the host's move token.
3762 const move = cssTimeMs(
3763 node.ownerDocument.defaultView?.getComputedStyle(node).getPropertyValue("--agent-motion-move") ?? "",
3764 );
3765 const duration = reduce
3766 ? 0
3767 : jumpDuration(target - node.scrollTop) * ((move ?? MOTION_MS.standard) / MOTION_MS.standard);
3768 jumpTarget.current = target;
3769 animateScroll(
3770 node,
3771 () =>
3772 Math.max(
3773 0,
3774 node.scrollHeight > 0
3775 ? node.scrollHeight - node.clientHeight
3776 : latestLayout.current.layout.totalHeight - node.clientHeight,
3777 ),
3778 duration,
3779 JUMP_CURVE,
3780 () => {
3781 jumpTarget.current = undefined;
3782 scrolledTo.current = { top: node.scrollTop, atLatest: true };
3783 seenAnchor.current = null;
3784 followTrace.set(node.scrollTop);
3785 scrollPacer.current?.cancel();
3786 setScrollAt(node.scrollTop, 0);
3787 },
3788 () => {
3789 // A native turn/bookmark command can replace the shared ride without a DOM input event.
3790 // Explicit stopJump disowns first; only an external replacement releases bottom follow.
3791 if (jumpTarget.current === undefined) return;
3792 jumpTarget.current = undefined;
3793 scrolledTo.current = { top: node.scrollTop, atLatest: false };
3794 follow(false);
3795 },
3796 );
3797 };
3798 useEffect(() => {
3799 const node = ref.current;
3800 const followIntent = (next: boolean) => followRef.current(next);
3801 let dragging = false;
3802 let pointerY: number | undefined;
3803 let touchY: number | undefined;
3804 const reader = (event?: Event) => {
3805 const pointer = event?.type === "pointermove" ? (event as PointerEvent) : undefined;
3806 const touch = event?.type === "touchmove" ? (event as TouchEvent).touches[0] : undefined;
3807 const movingUp =
3808 event !== undefined &&
3809 ((event.type === "wheel" && (event as WheelEvent).deltaY < 0) ||
3810 (event.type === "keydown" && ["ArrowUp", "PageUp", "Home"].includes((event as KeyboardEvent).key)) ||
3811 (pointer !== undefined &&
3812 pointerY !== undefined &&
3813 (pointer.pointerType === "touch" ? pointer.clientY > pointerY : pointer.clientY < pointerY)) ||
3814 (touch !== undefined && touchY !== undefined && touch.clientY > touchY));
3815 if (event?.type === "pointerdown") {
3816 dragging = true;
3817 pointerY = (event as PointerEvent).clientY;
3818 } else if (pointer) pointerY = pointer.clientY;
3819 if (event?.type === "touchstart") touchY = (event as TouchEvent).touches[0]?.clientY;
3820 else if (touch) touchY = touch.clientY;
3821 readerAt.current = performance.now();
3822 followTrace.input();
3823 // Release the follow intent synchronously when a reader starts moving up. The browser can
3824 // deliver the input before its scroll event; a streamed snapshot in that gap must not pin
3825 // the view back to the bottom. Capture the event so nested rows cannot commit first.
3826 if (movingUp) followIntent(false);
3827 if (jumpTarget.current !== undefined) {
3828 stopJump();
3829 scrolledTo.current = { top: node?.scrollTop ?? 0, atLatest: false };
3830 followIntent(false);
3831 }
3832 };
3833 // A long scrollbar drag/touch gesture remains reader input after its initial press expires.
3834 // Track a drag outside the scroller too, without treating unrelated pointer motion as input.
3835 const readerMove = (event: PointerEvent) => {
3836 if (!event.buttons) dragging = false;
3837 if (dragging) reader(event);
3838 };
3839 const readerReleased = () => {
3840 dragging = false;
3841 };
3842 const ownerDocument = node?.ownerDocument;
3843 ownerDocument?.addEventListener("pointermove", readerMove, { capture: true, passive: true });
3844 ownerDocument?.addEventListener("pointerup", readerReleased, { capture: true });
3845 ownerDocument?.addEventListener("pointercancel", readerReleased, { capture: true });
3846 const inputs = ["wheel", "pointerdown", "touchstart", "touchmove", "keydown"] as const;
3847 for (const name of inputs) node?.addEventListener(name, reader, { capture: true, passive: true });
3848 // A click on a disclosure line holds it where it is (foldAnchor.ts), before the row's own handler
3849 // opens it.
3850 // A turn fold still sliding rows lands first, and the offset takes up however far that moved the
3851 // clicked line, so the hold keeps it on the pixel it was clicked at.
3852 const settleFold = (line: HTMLElement) => {
3853 if (!turnFold.current) return;
3854 const before = line.getBoundingClientRect().top;
3855 settleTurnFold(thread.current, turnFold.current, foldGhosts.current);
3856 turnFold.current = undefined;
3857 const moved = line.getBoundingClientRect().top - before;
3858 if (node && Math.abs(moved) >= 0.5) node.scrollTop += moved;
3859 };
3860 const holdClicked = (event: MouseEvent) => {
3861 const toggle = node ? foldToggleAt(event.target, node) : undefined;
3862 if (!toggle) return;
3863 settleFold(toggle);
3864 foldAnchor.current = holdFold(toggle);
3865 };
3866 // A collapsible's header that asks to be held (useAnchoredCollapse.ts): a toggle from the keyboard
3867 // or a header that is no disclosure button, as well as the click above.
3868 const holdAsked = (event: Event) => {
3869 const header = event.target as HTMLElement | null;
3870 if (!node || typeof header?.closest !== "function" || !node.contains(header) || !header.closest(".acpmux-row"))
3871 return;
3872 event.preventDefault();
3873 settleFold(header);
3874 foldAnchor.current = holdFold(header);
3875 };
3876 // The reader scrolling lets go of it.
3877 const release = (event: Event) => {
3878 const key = (event as KeyboardEvent).key;
3879 if (key === "Enter" || key === " ") return;
3880 foldAnchor.current = undefined;
3881 };
3882 const releases = ["wheel", "touchstart", "keydown"] as const;
3883 node?.addEventListener("click", holdClicked, { capture: true });
3884 node?.addEventListener(ANCHORED_COLLAPSE_EVENT, holdAsked);
3885 for (const name of releases) node?.addEventListener(name, release, { passive: true });
3886 // The wheel over Jump to latest scrolls the transcript (wheelPassThrough.ts).
3887 // The chat's own transcript only (a replay's sits in its own screen).
3888 const main = node?.closest(".acpmux-stage")?.parentElement?.closest(".acpmux-main");
3889 const removeWheel = node && main ? installWheelPassThrough(node, main) : undefined;
3890 // A drag from the empty margins either side of the column selects text (gutterSelect.ts); the
3891 // view it scrolls past an edge is the reader's scroll.
3892 // A chat change ends any drag first (gutterRelease, below), so a hold is always this chat's.
3893 const gutter =
3894 node && thread.current
3895 ? installGutterSelect(

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '3158,3505p'" in /tmp/review-27311-ac90
succeeded in 0ms:
3158 previousRows.current = rows;
3159 scrolledTo.current = { top: 0, atLatest: false };
3160 seenAnchor.current = undefined;
3161 scrollPacer.current?.cancel();
3162 stopJump();
3163 setScrollAt(0, 0);
3164 follow(true);
3165 }
3166 const old = switched ? null : previousLayout.current;
3167 const oldRows = previousRows.current;
3168 const node = ref.current;
3169 // The view stopped following with its rows drawn up by the composer's growth (lift): that
3170 // growth is room now (heldRoom), and the offset moves by it in this same frame, so nothing the
3171 // reader sees moves.
3172 if (old && node && !following && appliedLift.current) {
3173 node.scrollTop -= appliedLift.current;
3174 scrolledTo.current = { ...scrolledTo.current, top: scrolledTo.current.top - appliedLift.current };
3175 // The rows mounted for this frame read the offset too, before its scroll event comes.
3176 const moved = node.scrollTop;
3177 scrollPacer.current?.cancel();
3178 setScrollAt(moved);
3179 }
3180 // A disclosure the reader clicked stays where they clicked it while what it opens lays out: this
3181 // pass neither follows the latest row nor keeps the top row, it puts the clicked line back.
3182 const heldFrom = node?.scrollTop ?? 0;
3183 const folding = Boolean(old && node && foldAnchor.current && keepFold(foldAnchor.current, node));
3184 if (!folding) foldAnchor.current = undefined;
3185 if (folding && node && Math.abs(node.scrollTop - heldFrom) >= 0.5) {
3186 // The rows mounted for this frame read the held offset too, before its scroll event comes.
3187 const moved = node.scrollTop;
3188 scrollPacer.current?.cancel();
3189 setScrollAt(moved);
3190 }
3191 if (folding && node) {
3192 // What opened pushed the latest row out of view: the view is the reader's now, not following.
3193 if (followingRef.current && node.scrollTop < layout.totalHeight - node.clientHeight - FOLLOW_BOTTOM_DEADBAND_PX)
3194 follow(false);
3195 } else if (old && node) {
3196 // Content that shrank under the viewport has already clamped the live offset to
3197 // the new end; the offset recorded before this commit is where the reader was.
3198 // A clamp lands exactly on the scroller's own end, which rounds the layout's
3199 // fractional height, so compare with that rather than allow for the rounding.
3200 // A viewport that grew clamps at the end the content had then: the room a queued prompt's
3201 // card leaves (floor) can make the content taller again in this same commit.
3202 const live = node.scrollTop;
3203 // The browser rounds the height it clamps to, so the old end allows a pixel either way.
3204 const end = Math.min(node.scrollHeight, old.totalHeight) - node.clientHeight;
3205 const clamped = live < scrolledTo.current.top - 0.5 && live >= end - 1;
3206 // A reader's move can reach the live offset before its scroll event. Layout changes alone
3207 // never release or reacquire follow; the input window only identifies this event race.
3208 const readerLeft =
3209 followingRef.current &&
3210 !clamped &&
3211 performance.now() - readerAt.current <= READER_SCROLL_MS &&
3212 scrolledTo.current.top - live > FOLLOW_BOTTOM_DEADBAND_PX;
3213 if (readerLeft) follow(false);
3214 // A real wheel can move the live offset before its scroll event reaches React. Keep that
3215 // movement for this pass and mark the view away from the latest row; otherwise the stored
3216 // bottom offset would immediately snap the reader back. Once the event is acknowledged,
3217 // layout-only corrections use the stored offset because WebKit may already include a resize
3218 // adjustment in live.
3219 const top = scrollAnchorBase(live, scrolledTo.current.top, readerLeft);
3220 // Following is reader intent, not proximity. A token must not recapture a parked reader
3221 // just because the last recorded offset remains inside the bottom deadband.
3222 const atLatest = followingRef.current;
3223 // At the latest row also at offset 0: a new chat's first pass can overshoot on estimated
3224 // heights and settle back to fit, and its reply must still be followed once it overflows
3225 // (cx-ubd1y). Away from it, at the first row nothing above can move it.
3226 if (didOpenAtLatest.current && atLatest) {
3227 // At the latest row: stay there as rows settle to their drawn heights and as content grows,
3228 // in this pass, before the frame paints. Nothing glides: a glide held the new text below the
3229 // view's foot for its 180 ms at every streamed chunk, and on open it slid from a layout that
3230 // never painted (estimated heights) to the drawn one (Leo 2026-10-21: "IMMEDIATELY ... at the
3231 // bottom every single time").
3232 const latest = Math.max(0, layout.totalHeight - node.clientHeight);
3233 // Jump to latest's glide in flight heads for the new end instead of snapping there.
3234 const jumping = jumpTarget.current !== undefined;
3235 // The shared animation reads the moving end every frame on its original clock.
3236 // Retargeting a native smooth-scroll here restarted its easing at every measurement.
3237 if (jumping) jumpTarget.current = latest;
3238 const settled = jumping ? clamped : settleFollowBottom(node, latest) || clamped;
3239 if (settled) followTrace.set(latest);
3240 } else if (top > 0 || rows[0]?.id !== oldRows[0]?.id) {
3241 // Keep the row at the top of the viewport where it is as rows above it change height.
3242 // Rows that arrived or left (a new reply segment below, older history above) move indexes,
3243 // so the row is found again by its id; at offset 0 too, when older history arrived above.
3244 // A row older history completed (a turn cut at the page's edge) has a new id: the next row
3245 // that kept its own anchors instead.
3246 const nextTop = preserveScrollAnchorTop({
3247 oldLayout: old,
3248 newLayout: layout,
3249 oldRows,
3250 newRows: rows,
3251 liveTop: top,
3252 anchorTop: top,
3253 });
3254 if (nextTop !== undefined && (clamped || Math.abs(nextTop - top) > 0.5)) {
3255 node.scrollTop = nextTop;
3256 // Keep React's rendered offset in the same frame as the anchor correction. Otherwise a
3257 // rounded DOM offset arrives through scroll later and can briefly show Jump to latest.
3258 scrollPacer.current?.cancel();
3259 setScrollAt(node.scrollTop);
3260 }
3261 }
3262 }
3263 // Runs on height too: rows that fit and then overflow on a height-only shrink keep the same memoized layout.
3264 if (!didOpenAtLatest.current && node) {
3265 const latest = Math.max(0, layout.totalHeight - node.clientHeight);
3266 // A chat reopened where the reader left it (cx-6bgzj), else at the latest message, as it is
3267 // once the reader sent a prompt into it.
3268 const at = openAt && !sentBeforeOpen.current ? rows.findIndex((row) => row.id === openAt.rowId) : -1;
3269 // That row is older than the pages loaded so far (a chat opens on its tail): page back to it
3270 // unseen, one older page after another: each page that lands changes rows and looks again
3271 // (cx-odzaz).
3272 if (openAt && at < 0 && canLoadOlder && onLoadOlder && !seekExpired) {
3273 const now = performance.now();
3274 seekStart.current ??= now;
3275 if (seekFirstRow.current !== rows[0]?.id || seekTimer.current === undefined) {
3276 seekFirstRow.current = rows[0]?.id;
3277 clearTimeout(seekTimer.current);
3278 const left = Math.min(OPEN_AT_PAGE_MS, OPEN_AT_SEEK_MS - (now - seekStart.current));
3279 seekTimer.current = setTimeout(() => setSeekExpired(true), Math.max(0, left));
3280 }
3281 setSeeking(true);
3282 onLoadOlder().catch(() => {});
3283 } else if (layout.totalHeight <= node.clientHeight) {
3284 // A tail that fits shows from the top, as before; it opens once it overflows.
3285 endSeek();
3286 setSeeking(false);
3287 } else {
3288 const top = at < 0 ? latest : Math.min(latest, layout.tops[at] + openAt!.offset);
3289 node.scrollTop = top;
3290 followTrace.set(top);
3291 scrollPacer.current?.cancel();
3292 setScrollAt(top, 0);
3293 follow(top >= latest - 1);
3294 didOpenAtLatest.current = true;
3295 endSeek();
3296 setSeeking(false);
3297 }
3298 }
3299 previousLayout.current = layout;
3300 previousRows.current = rows;
3301 previousViewport.current = { height, width, dockSpace };
3302 // With the composer's growth, which is room again once the view stops following.
3303 heldRoom.current = room + lifted;
3304 appliedLift.current = lift;
3305 liftedBefore.current = lifted;
3306 // A view that follows keeps the card height it began following at; any other takes the card's.
3307 if (!lifting) cardRest.current = dockCard;
3308 // The room a followed view keeps (floor above), until the rows fill it or it stops following.
3309 const holding = didOpenAtLatest.current && followingRef.current && floorHeight - lifted > rowsHeight;
3310 // Held for a sent prompt: once, for the prompt to arrive. Held only for a withdrawn one (the
3311 // reply was working): again at each pass, so it lasts while the reply grows and goes soon after.
3312 const forSent = [...heldFor.current].some((id) => !withdrawn.current.has(id));
3313 if (holding && (!floor.current.height || !forSent)) {
3314 clearTimeout(floorTimer.current);
3315 floorTimer.current = setTimeout(
3316 () => {
3317 floor.current = { ...floor.current, height: 0 };
3318 releaseFloor();
3319 },
3320 forSent ? FLOOR_HOLD_MS : WITHDRAWN_HOLD_MS,
3321 );
3322 } else if (!holding) clearTimeout(floorTimer.current);
3323 floor.current = { height: holding ? floorHeight : 0, width, sessionId };
3324 if (!holding && !queueLeft.current) heldFor.current.clear();
3325 if (node && node.clientHeight > 0 && didOpenAtLatest.current) {
3326 scrolledTo.current = { top: node.scrollTop, atLatest: followingRef.current };
3327 seenAnchor.current = restingAnchor(node.scrollTop, scrolledTo.current.atLatest, layout, rows);
3328 }
3329 followTrace.update(node, layout.totalHeight, scrolledTo.current.atLatest);
3330 // The minimap's band reads the offset this pass settled on.
3331 minimapPlaced.current?.();
3332 // eslint-disable-next-line react-hooks/exhaustive-deps -- openAt is read once, at the first open
3333 }, [layout, rows, range.first, height, width, dockSpace, room, lift, sessionId, seekExpired, canLoadOlder]);
3334 // A toggled "Worked for" plays when its rows are placed again (watchTurnFold, started at the
3335 // toggle), not on every render. A render only checks, in O(1), whether the fold is over or another
3336 // chat shows, so nothing of it is left drawn over the new rows.
3337 useLayoutEffect(() => {
3338 const fold = turnFold.current;
3339 if (!fold || (performance.now() <= fold.until && fold.sessionId === sessionId)) return;
3340 settleTurnFold(thread.current, fold, foldGhosts.current);
3341 turnFold.current = undefined;
3342 });
3343 // Unmounted: the playing fold stops and lets go of its rows, and its copies go.
3344 useEffect(
3345 () => () => {
3346 settleTurnFold(thread.current, turnFold.current, foldGhosts.current);
3347 turnFold.current = undefined;
3348 for (const ghost of foldGhosts.current) ghost.remove();
3349 foldGhosts.current.clear();
3350 },
3351 [],
3352 );
3353 // Read at the click, so the callback stays the same across snapshots (rows compare it).
3354 const foldInputs = useRef({ layout, expanded, sessionId });
3355 foldInputs.current = { layout, expanded, sessionId };
3356 /// The reader's toggle of a row that opens or closes other rows ("Worked for", a subagent group):
3357 /// its motion starts from the rows as they are drawn now (turnFold.ts), then the pane toggles it.
3358 const toggleRow = useCallback(
3359 (id: string) => {
3360 const node = ref.current;
3361 const shown = rowsRef.current;
3362 const host = thread.current;
3363 settleTurnFold(host, turnFold.current, foldGhosts.current);
3364 const fold = node
3365 ? startTurnFold(
3366 host,
3367 id,
3368 !foldInputs.current.expanded.has(id),
3369 { count: shown.length, idAt: (index) => shown[index]?.id, tops: foldInputs.current.layout.tops },
3370 foldInputs.current.sessionId,
3371 )
3372 : undefined;
3373 turnFold.current = fold;
3374 // The rows as placed by the latest render, read when a pass runs.
3375 const read = () => {
3376 const placed = rowsRef.current;
3377 const { layout, sessionId } = foldInputs.current;
3378 return {
3379 placed: { count: placed.length, idAt: (index: number) => placed[index]?.id, tops: layout.tops },
3380 sessionId,
3381 };
3382 };
3383 if (fold && host)
3384 watchTurnFold(fold, host, read, foldGhosts.current, () => {
3385 if (turnFold.current === fold) turnFold.current = undefined;
3386 });
3387 onToggleActivity(id);
3388 },
3389 [onToggleActivity],
3390 );
3391 // Commit before this frame paints; deferring to the next animation frame left the edge blank.
3392 // The page picks adaptive rendering; the host supplies the display interval and applies it.
3393 const renderRate = useMemo(() => new AdaptiveRenderRate(), []);
3394 const pacing = useMemo(
3395 () => new ScrollPacing((intervals) => void reportScrollPacing(intervals, callNative, renderRate)),
3396 [renderRate],
3397 );
3398 useEffect(() => () => pacing.stop(), [pacing]);
3399 // Find in Chat: a new current match scrolls its row in (it may not be mounted), then, once its
3400 // text is drawn, the match itself; every commit repaints the highlights over the mounted rows.
3401 const findQuery = find?.open ? find.query : "";
3402 const findTarget = find?.matches[find.active];
3403 const findTargetRowId = findTarget?.rowId;
3404 const findTargetOccurrence = findTarget?.occurrence;
3405 // Find matches the chat's rows; the transcript draws a match's row (or its copy in an open fold)
3406 // once its fold is open.
3407 const findIndex = findTargetRowId !== undefined ? findRowIndex(rows, findTargetRowId) : -1;
3408 const drawnId = rows[findIndex]?.id;
3409 const drawnCard = Boolean(rows[findIndex]?.ended);
3410 // A card of waiting messages draws its messages in one row (conversation/WaitingMessagesCard.tsx):
3411 // which of them holds the match.
3412 const findMember =
3413 rows[findIndex]?.kind === WAITING_MESSAGES && findTargetRowId !== drawnId ? findTargetRowId : undefined;
3414 const revealing = useRef<string | undefined>(undefined);
3415 const targetKey =
3416 findTargetRowId !== undefined && findTargetOccurrence !== undefined && findQuery && drawnId !== undefined
3417 ? ${drawnId}:${drawnCard}:${findTargetOccurrence}:${findQuery}${findMember ? :${findMember} : ""}
3418 : undefined;
3419 // A group or call that opens to show the match asks for a render here, so the highlights and
3420 // the scroll below see its text.
3421 const [revealed, setRevealed] = useState(0);
3422 // Keyed on what it names, not the match object, which is new on each update while Find is open:
3423 // a new value would open again every group a reader closed.
3424 const findReveal = useMemo(
3425 () =>
3426 drawnId !== undefined && findQuery
3427 ? {
3428 rowId: drawnId,
3429 card: drawnCard,
3430 needle: findQuery.toLocaleLowerCase(),
3431 repaint: () => setRevealed((count) => count + 1),
3432 ...(findMember ? { member: findMember } : {}),
3433 }
3434 : undefined,
3435 [drawnId, drawnCard, findQuery, findMember],
3436 );
3437 // Find's scroll is the reader's: it stops following now, not when the browser reports the
3438 // scroll a frame later, or a re-measure before then puts the view back at the latest row.
3439 // It reads the latest layout, since the reveal hold calls it from frames after this render.
3440 const findTotalHeight = useRef(layout.totalHeight);
3441 findTotalHeight.current = layout.totalHeight;
3442 const findMoved = useCallback(
3443 (node: HTMLDivElement) => {
3444 // A Find match moved the view: a Jump to latest in flight ends here, not at the latest row.
3445 stopJump();
3446 scrolledTo.current = scrollPosition(node, findTotalHeight.current);
3447 followingRef.current = scrolledTo.current.atLatest;
3448 setFollowing(scrolledTo.current.atLatest);
3449 },
3450 [stopJump],
3451 );
3452 // The current match's reveal window: 0 until it starts, then the window's own number.
3453 const revealWindow = useRef(0);
3454 const revealWindows = useRef(0);
3455 useLayoutEffect(() => {
3456 revealing.current = targetKey;
3457 revealWindow.current = 0;
3458 const node = ref.current;
3459 if (!node || !findTarget || !targetKey) return;
3460 const top = layout.tops[findIndex];
3461 const bottom = top + layout.heights[findIndex];
3462 // In view where it draws (lift from its place while the rows follow a grown composer).
3463 const drawn = node.scrollTop - liftRef.current;
3464 if (top >= drawn && bottom <= drawn + node.clientHeight - dockSpaceRef.current) return;
3465 unlift();
3466 node.scrollTop = Math.max(0, top - node.clientHeight / 3);
3467 findMoved(node);
3468 // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new target scrolls, not a layout change
3469 }, [targetKey]);
3470 // A row the running line's count names scrolls in once the transcript draws it (its fold may
3471 // open first), as a Find match does.
3472 const revealIndex = reveal ? findRowIndex(rows, reveal.rowId) : -1;
3473 const countReveal = useMemo(
3474 () =>
3475 reveal && revealIndex >= 0
3476 ? { rowId: rows[revealIndex]!.id, card: false, needle: "", toolId: reveal.toolId, repaint: () => {} }
3477 : undefined,
3478 // eslint-disable-next-line react-hooks/exhaustive-deps -- one reveal per key, once its row is drawn
3479 [reveal?.key, revealIndex >= 0],
3480 );
3481 useLayoutEffect(() => {
3482 const node = ref.current;
3483 if (!node || revealIndex < 0) return;
3484 const top = layout.tops[revealIndex];
3485 const bottom = top + layout.heights[revealIndex];
3486 // In view where it draws (lift from its place while the rows follow a grown composer).
3487 const drawn = node.scrollTop - liftRef.current;
3488 if (top >= drawn && bottom <= drawn + node.clientHeight - dockSpaceRef.current) return;
3489 unlift();
3490 // The reader asked for this row: the view stops following the reply, or the run opening below
3491 // would pull it back to the latest line.
3492 const next = Math.max(0, top - node.clientHeight / 3);
3493 stopJump();
3494 scrolledTo.current = { top: next, atLatest: false };
3495 follow(false);
3496 node.scrollTop = next;
3497 // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new reveal scrolls, not a layout change
3498 }, [reveal?.key, revealIndex >= 0]);
3499 useLayoutEffect(() => {
3500 const node = ref.current;
3501 if (!node) return;
3502 if (!findQuery) return clearFindHighlights();
3503 const current = paintFindHighlights(
3504 node,
3505 findQuery,

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "rg -n "function syntheticRows|syntheticRows" files/webviews/src/agent-session/acpmux/synthetic.ts files/webviews/src/agent-session/acpmux -g '.ts' && nl -ba files/webviews/src/agent-session/acpmux/synthetic.ts | sed -n '1,240p'" in /tmp/review-27311-ac90
exited 2 in 0ms:
rg: files/webviews/src/agent-session/acpmux/synthetic.ts: No such file or directory (os error 2)

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "rg --files files/webviews/src | rg 'synthetic' && rg -n "syntheticRows" files/webviews/src files/webviews/test | head -30" in /tmp/review-27311-ac90
exited 1 in 0ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "rg -n "function wheelTarget|export.wheelTarget" files/webviews/src -g '.ts*' && sed -n '1,180p' files/webviews/src/scrollers.ts 2>/dev/null || true" in /tmp/review-27311-ac90
succeeded in 0ms:
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "find files/webviews/src/agent-session/acpmux -maxdepth 2 -type f -printf '%p\n' | sort | head -100 && find files/webviews/test -maxdepth 2 -type f -printf '%p\n' | sort" in /tmp/review-27311-ac90
succeeded in 0ms:
files/webviews/src/agent-session/acpmux/App.tsx
files/webviews/src/agent-session/acpmux/threadMinimap/scrollMotion.ts
files/webviews/src/agent-session/acpmux/wheelGlide.ts
files/webviews/src/agent-session/acpmux/wheelPassThrough.ts
files/webviews/test/agent-pane-wheel-glide.test.ts
files/webviews/test/browser/transcript-older-scroll.harness.tsx
files/webviews/test/browser/transcript-older-scroll.html
files/webviews/test/reopened-chat-history.test.ts
files/webviews/test/transcript-older-scroll.test.ts
files/webviews/test/wheel-glide-motion.test.ts

hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
hook: PreToolUse Completed
exec
/bin/bash -lc "rg -n "function VirtualTranscript|export function VirtualTranscript|<VirtualTranscript|canLoadOlder|historyFrom|openAt" files/webviews/src/agent-session/acpmux/App.tsx | head -100 && nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '2030,2145p'; nl -ba files/webviews/src/agent-session/acpmux/App.tsx | sed -n '4300,4500p'" in /tmp/review-27311-ac90
succeeded in 0ms:
605: canLoadOlder: false,
2030:export function VirtualTranscript({
2037: canLoadOlder = false,
2038: historyFrom,
2052: openAt,
2066: openAt?: ScrollAnchor;
2098: canLoadOlder?: boolean;
2099: /// The oldest event seq held (AcpmuxSnapshot.historyFrom): a read that drew no row still moves it.
2100: historyFrom?: number;
2101: /// Pages older history in (canLoadOlder): asked once per loaded page while the view is near the top.
2635: /// The open waits, unseen, while older pages bring back openAt's row; expired, it opens at the latest.
2640: /// A prompt was sent before the chat opened (while it seeks openAt's row, or while its tail still
3147: // openAt position or latest row.
3268: const at = openAt && !sentBeforeOpen.current ? rows.findIndex((row) => row.id === openAt.rowId) : -1;
3272: if (openAt && at < 0 && canLoadOlder && onLoadOlder && !seekExpired) {
3288: const top = at < 0 ? latest : Math.min(latest, layout.tops[at] + openAt!.offset);
3332: // eslint-disable-next-line react-hooks/exhaustive-deps -- openAt is read once, at the first open
3333: }, [layout, rows, range.first, height, width, dockSpace, room, lift, sessionId, seekExpired, canLoadOlder]);
3953: // A read that drew no row above the first (records that draw nothing) moves historyFrom: ask again.
3954: const olderLoadKey = ${sessionKey}\u0000${rows[0]?.id ?? ""}\u0000${historyFrom ?? ""};
3969: if (!canLoadOlder || !onLoadOlder || !node || node.clientHeight === 0) {
4006: }, [canLoadOlder, height, onLoadOlder, olderLoadKey, renderScroll.top]);
4124: setSize={canLoadOlder ? -1 : rows.length}
5687: !snapshot.canLoadOlder,
7210: setSnapshot((current) => ({ ...current, rows, connection: "debug", isWorking: false, canLoadOlder: false }));
8961: <VirtualTranscript
8980: canLoadOlder={!childRows && snapshot.canLoadOlder}
8981: historyFrom={childRows ? undefined : snapshot.historyFrom}
8985: openAt={
10160: <VirtualTranscript
2030 export function VirtualTranscript({
2031 rows,
2032 sessionId,
2033 onToggleActivity,
2034 onOpenDiff,
2035 expanded,
2036 registry = defaultRegistry,
2037 canLoadOlder = false,
2038 historyFrom,
2039 githubRepository,
2040 fallbackRepository,
2041 sourceRows,
2042 find,
2043 reveal,
2044 working = false,
2045 jumpButton = false,
2046 needsYou,
2047 dock,
2048 queueWithdrawn,
2049 queueIds,
2050 queuedTail,
2051 focusComposer,
2052 openAt,
2053 onAnchor,
2054 anchorNow,
2055 onLoadOlder,
2056 sends = 0,
2057 onFollowingChange,
2058 coldOpen,
2059 onColdOpenShown,
2060 afterLatest,
2061 showMinimap = true,
2062 }: {
2063 rows: readonly AcpmuxRow[];
2064 sessionId?: string;
2065 /// Where the transcript opens instead of the latest message, when that row is loaded.
2066 openAt?: ScrollAnchor;
2067 /// Where it rests after each settled scroll (null at the latest message).
2068 onAnchor?: (anchor: ScrollAnchor | null) => void;
2069 /// Filled with where it last rested in view (null at the latest message, undefined when it was
2070 /// never in view), for a page about to let go.
2071 anchorNow?: React.MutableRefObject<(() => ScrollAnchor | null | undefined) | undefined>;
2072 /// A reply is streaming: Jump to latest remains static while the shared transcript spinner carries
2073 /// the working state.
2074 working?: boolean;
2075 /// Draw Jump to latest: the chat's own stage only, which positions it above the composer.
2076 jumpButton?: boolean;
2077 /// Open asks: Jump to latest becomes the Needs your input pill (needsYou/JumpPill.tsx).
2078 needsYou?: TranscriptNeedsYou;
2079 /// The chat's bottom stack, laid over the transcript's foot (bottomStack.css): the view runs under
2080 /// it, the scrollbar's track to the pane's bottom as in the Claude and Codex apps, and its end sits
2081 /// above it. Its height is room under the last row, followed as it changes before the frame paints.
2082 dock?: HTMLElement | null;
2083 /// Prompts withdrawn from the queue (snapshot queueWithdrawn): no row comes for one whose card
2084 /// leaves the stack, so the view keeps no room for it.
2085 queueWithdrawn?: readonly string[];
2086 /// IDs from the queue snapshot: a folded prompt has no card in the DOM, but remains queued and
2087 /// must not be recorded as withdrawn when the fold changes.
2088 queueIds?: readonly string[];
2089 /// The waiting agent messages at the rows' end (conversation/queuedMessages.ts), one array while
2090 /// the queue holds them.
2091 queuedTail?: readonly AcpmuxRow[];
2092 /// Where a keyboard Jump to latest leaves focus, since the button hides (inert) once there.
2093 focusComposer?: () => void;
2094 onToggleActivity: (id: string) => void;
2095 onOpenDiff?: OpenDiff;
2096 expanded: Set;
2097 registry?: NativeRegistry;
2098 canLoadOlder?: boolean;
2099 /// The oldest event seq held (AcpmuxSnapshot.historyFrom): a read that drew no row still moves it.
2100 historyFrom?: number;
2101 /// Pages older history in (canLoadOlder): asked once per loaded page while the view is near the top.
2102 onLoadOlder?: () => Promise;
2103 /// How many prompts this pane has sent: each new one puts the view at the latest message.
2104 sends?: number;
2105 /// Keeps the parent send path in sync with the reader's current follow state.
2106 onFollowingChange?: (following: boolean) => void;
2107 githubRepository?: string;
2108 /// The repository a bare #N means when githubRepository is unknown and the transcript names
2109 /// none before it (the header pull request's).
2110 fallbackRepository?: string;
2111 /// The client's rows, which still hold the work a closed "Worked for" folds away: where a bare
2112 /// #N's repository is looked for (conversation/bareReferences.ts). Else rows.
2113 sourceRows?: readonly AcpmuxRow[];
2114 /// Find in Chat (chatFind.ts): its bar, the query to highlight and the match to bring into view.
2115 find?: ChatFind;
2116 /// A row to bring into view (a count on the running line, WorkingFor.tsx); a new key asks again.
2117 /// The run that holds call toolId opens, as for a Find match.
2118 reveal?: { rowId: string; key: number; toolId: string };
2119 /// A chat the page switched to and read cold, by its open (a new number per open), whose rows
2120 /// have not shown yet: they fade in once, on the frame they first draw (fadeInOpenedChat).
2121 /// Undefined for a chat drawn from a kept tail, which shows whole on the frame the switch lands
2122 /// ("instant if cached"), for the first chat a page shows, which the host reveals
2123 /// (pane.painted), and once the open's rows have shown.
2124 coldOpen?: number;
2125 /// The rows of open coldOpen have shown. The pane keeps that, not this transcript, so a
2126 /// transcript mounted again (back from New Tab or a replay) does not fade the same open twice.
2127 onColdOpenShown?: (open: number) => void;
2128 /// Optional content placed immediately after the latest message, outside the scroller.
2129 afterLatest?: React.ReactNode;
2130 /// The focused chat's palette action can hide this pane's rail without changing the configured
2131 /// minimap experiment or any other pane's layout.
2132 showMinimap?: boolean;
2133 }) {
2134 const t = useT();
2135 // Debug measurement (acpmuxPerf): off until the first debug call.
2136 const renderStart = acpmuxPerf.enabled ? performance.now() : 0;
2137 acpmuxPerf.span("vt.renders", 1);
2138 const scrollRef = useRef({ top: 0, delta: 0 });
2139 const [, setScroll] = useState(scrollRef.current);
2140 const setScrollAt = (top: number, delta = top - scrollRef.current.top) => {
2141 const next = { top, delta };
2142 scrollRef.current = next;
2143 setScroll(next);
2144 };
2145 /// The view follows the latest row (scrolledTo.atLatest, as of the last scroll). Jump to latest shows
4300 /// The chips belong to a page with no chat yet: a model, mode or effort pick starts one first.
4301 startsChat?: boolean;
4302 }) {
4303 const inFolder = startsChat ? { ...(cwd ? { cwd } : {}), deferred: true } : {};
4304 const addAgent = useHarnessesPage();
4305 const signIn = useSignIn();
4306 // acpmux has answered with its harnesses: only then can the chip say none is installed.
4307 const harnessesListed = useHarnessesListed();
4308 // chat.new for the same agent and folder is the switch already running, so a second pick
4309 // never restarts it.
4310 const start = () => {
4311 const harness = snapshot.summary?.harness;
4312 if (!startsChat || !harness || snapshot.summary?.sessionId) return;
4313 void callNative("chat.new", { harness, ...inFolder }).catch(() => undefined);
4314 };
4315 const picker = usePickerCatalog(
4316 snapshot.catalog,
4317 {
4318 harness: snapshot.summary?.harness,
4319 model: snapshot.summary?.model,
4320 configOptions: snapshot.summary?.configOptions,
4321 },
4322 snapshot.sessions,
4323 );
4324 const [refreshStatus, setRefreshStatus] = useState<"idle" | "fetching" | "updated" | "error">("idle");
4325 const refreshCatalog = useCallback(async () => {
4326 setRefreshStatus("fetching");
4327 try {
4328 await picker.refresh();
4329 setRefreshStatus("updated");
4330 } catch {
4331 setRefreshStatus("error");
4332 // l10n-allow: a developer error for the refresh caller; the picker shows refreshStatus, never this text.
4333 throw new Error("models.catalog refresh failed");
4334 }
4335 }, [picker]);
4336 return (
4337 <ComposerPickers
4338 snapshot={startsChat ? newTabChipSnapshot(snapshot, picker.catalog) : snapshot}
4339 // A New Tab pick is held once acpmux's actions are connected and the chat has a harness to
4340 // start (start() sends chat.new first); before that a pick would reach nothing.
4341 holdsModePick={
4342 snapshot.switching !== undefined ||
4343 (startsChat && !!snapshot.summary?.harness && window.cmuxAcpmuxActions?.["chat.new"] !== undefined)
4344 }
4345 permissionAccess={permissionAccess}
4346 onModel={(modelId) => {
4347 start();
4348 void callNative("chat.model", { modelId });
4349 }}
4350 onMode={(modeId) => {
4351 start();
4352 return callNative("chat.mode", { modeId });
4353 }}
4354 onEffort={(configId, value) => {
4355 start();
4356 void callNative("chat.effort", { configId, value });
4357 }}
4358 onHarness={(harness, model, options) =>
4359 void callNative("chat.new", {
4360 harness,
4361 ...(model ? { model } : {}),
4362 ...(options ? { options } : {}),
4363 ...inFolder,
4364 })
4365 }
4366 // A started chat forks to another harness through Continue In… (Leo 2026-10-22: "need to be
4367 // able to switch to different harnesses, can expose as fork"): the chat menu's own path, its
4368 // review in this tab, then the new chat in a tab of its own. Not while a turn or queue runs.
4369 {...(!startsChat && snapshot.canHandoff
4370 ? {
4371 fork: {
4372 to: (harness: string) => void callNative("chat.handoff.prepare", { harness }).catch(() => undefined),
4373 // Continue In…'s own check: no fork while a review is open or no harness can take it.
4374 ready: canContinueIn(snapshot),
4375 },
4376 }
4377 : {})}
4378 {...(cwd ? { folder: cwd } : {})}
4379 // Sent from the pick's own handler: the host's Enable confirmation needs the gesture.
4380 onHarnessEnable={(folder, id) => void callNative("chat.harness.enable", { folder, id }).catch(() => undefined)}
4381 // Add harness… (the picker's last row; the chip itself with no harness): the Harnesses page,
4382 // in place, from this click's gesture.
4383 {...(addAgent ? { onAddAgent: addAgent } : {})}
4384 {...(signIn ? { signIn } : {})}
4385 harnessesListed={harnessesListed}
4386 showPlan={false}
4387 pickerCatalog={picker.catalog}
4388 catalogRefresh={{ status: refreshStatus, date: picker.date, refresh: refreshCatalog }}
4389 // A prewarm hint for the direct client only: the native host has no daemon to warm.
4390 onHarnessHint={(harness) => void window.cmuxAcpmuxActions?.["chat.harness.hint"]?.({ harness })}
4391 />
4392 );
4393 }
4394
4395 export function AcpmuxApp() {
4396 const [queryClient] = useState(createPaneQueryClient);
4397 return (
4398
4399
4400
4401 );
4402 }
4403
4404 /// No projects known (a stable value, so memos keyed on it hold).
4405 const NO_PROJECTS: Project[] = [];
4406
4407 function AcpmuxPane() {
4408 // Debug measurement (acpmuxPerf): the pane's own render, before its children.
4409 const paneRenderStart = acpmuxPerf.enabled ? performance.now() : 0;
4410 const t = useT();
4411 const { scrollToBottomOnSend } = useComposerSettings();
4412 const scrollToBottomOnSendRef = useRef(scrollToBottomOnSend);
4413 scrollToBottomOnSendRef.current = scrollToBottomOnSend;
4414 /// Labs preview features are off until the host explicitly enables them.
4415 const [preview, setPreview] = useState(false);
4416 /// A new chat's Run as (preview, hq#3367): Persistent is the chat as before; Sandboxed runs the
4417 /// first message once on a throwaway Cloud VM and shows its receipt instead. The host holds this
4418 /// tab's one Sandboxed send and the page renders what it says (sandboxRun.ts SandboxTab). A refused
4419 /// send's prompt goes back to the composer, or waits for it (heldBack).
4420 /// A Sandboxed refusal moved a draft into the chat composer: the chat view stays (with that
4421 /// composer, not the New Tab page that would remount without it) until a chat starts here or the
4422 /// host hands the page another new chat.
4423 const [sandboxDraftMoved, setSandboxDraftMoved] = useState(false);
4424 const [sandbox] = useState(
4425 () =>
4426 new SandboxTab(callNative, (text) => {
4427 setSandboxDraftMoved(true);
4428 if (composerHandle.current) composerHandle.current.restore(text, []);
4429 else heldBack.current = [...(heldBack.current ?? []), { text, attachments: [] }];
4430 }),
4431 );
4432 useSyncExternalStore(sandbox.subscribe, sandbox.snapshot);
4433 const [workflowLabsEnabled, setWorkflowLabsEnabled] = useState(false);
4434 /// What a chat opened from another tab inherited (#16620); the composer starts with it.
4435 const [draft, setDraft] = useState<string | undefined>();
4436 const [newSession, setNewSession] = useState(false);
4437 /// The chat whose recorded history plays back read-only in place of it (chat menu Replay, cx-osmw).
4438 const [replaying, setReplaying] = useState<string | undefined>();
4439 /// The focused pane's rail visibility. The experiment still chooses compact/full when visible;
4440 /// this local switch is only the command-palette toggle and does not write shared experiment state.
4441 const [minimapVisible, setMinimapVisible] = useState(true);
4442 // Why the last /btw did not start its side session, for the chat it was asked in; "" when
4443 // acpmux gave no reason.
4444 const [btwFailure, setBtwFailure] = useState<{ sessionId: string; reason: string }>();
4445 const [statusCardOpen, setStatusCardOpen] = useState(false);
4446 // The host answered the handshake: the page shows what it is (a new chat's hero, a session).
4447 const [handshaken, setHandshaken] = useState(false);
4448 const handshakenRef = useRef(false);
4449 /// The handshake named a chat acpmux still has to attach: the page draws it once the client's
4450 /// first frame (its rows, title and modes) is in, or the open's hold has passed.
4451 const [awaitsChat, setAwaitsChat] = useState(false);
4452 /// New Tab submits made before acpmux answered, run in order once it has (never dropped).
4453 const pendingSubmits = useRef<Array<() => void>>([]);
4454 const whenHandshaken = (run: () => void) => {
4455 if (handshakenRef.current) run();
4456 else pendingSubmits.current.push(run);
4457 };
4458 /// The New Tab screen's actions with its submits (a prompt, a recent session) held until the
4459 /// handshake: the field types from its first render, a chat needs acpmux.
4460 const withSubmitAfterHandshake = (actions: NewTabScreenActions): NewTabScreenActions => ({
4461 ...actions,
4462 onAsk: (...args) => whenHandshaken(() => actions.onAsk(...args)),
4463 onOpenSession: (...args) => whenHandshaken(() => actions.onOpenSession(...args)),
4464 });
4465 // An unsent chat can choose its folder even before an agent is available.
4466 const [projectDraft, setProjectDraft] = useState<string | undefined>();
4467 /// The host offers Choose Folder… (a new chat in a workspace without a folder).
4468 const [chooseFolder, setChooseFolder] = useState(false);
4469 /// A chat that opened without its folder (cx-nn3e.1): acpmux's reason, until a pick works.
4470 const [folderNeeded, setFolderNeeded] = useState<{ reason: string; error?: string }>();
4471 const [pastTurns, setPastTurns] = useState<PastTurn[]>();
4472 /// The past turns are an adopted chat's own, replayed once its folder's trust question is
4473 /// answered: they show only while that question is up, so a trusted folder never flashes them.
4474 const [pastWaitsForTrust, setPastWaitsForTrust] = useState(false);
4475 /// The host's localized refusal of the last Choose Folder… click.
4476 const [folderError, setFolderError] = useState<string | undefined>();
4477 /// A folder the user picked that waits for their answer (startFolder.tsx): the question shows
4478 /// above the composer, and use runs with the folder once they answer Use Home Folder.
4479 /// sessionId is the chat the pick was for: a chat that started or changed meanwhile drops it.
4480 const [folderAsk, setFolderAsk] = useState<{
4481 ask: FolderAsk;
4482 use(cwd: string): void;
4483 cancel?(): void;
4484 sessionId?: string;
4485 error?: string;
4486 }>();
4487 const folderConfirmGeneration = useRef(0);
4488 const folderConfirmInFlight = useRef(false);
4489 const [folderConfirming, setFolderConfirming] = useState(false);
4490 const [importError, setImportError] = useState<string | undefined>();
4491 // Edit CLAUDE.md the app refused (a link out of the folder, not a file): the notice above the
4492 // composer says why, with Retry; opening it clears the notice.
4493 const [instructionsFailure, setInstructionsFailure] = useState<
4494 { cwd: string; failure: InstructionsFailure } | undefined
4495 >();
4496 // The shown chat's folder: an answer for a folder the pane has left changes nothing.
4497 const instructionsFolder = useRef<string | undefined>(undefined);
4498 const openInstructions = useCallback((cwd: string, name: string) => {
4499 void callNative("project.instructions", { cwd, name }).then(
4500 () => {

codex
SHIP

  1. files/webviews/src/agent-session/acpmux/wheelGlide.ts:84-102 — Severity: none. Registration order is correct: the precise event stops any glide, then the watcher removes the cancelling listener. The first notch after removal only re-arms it and scrolls natively; the following notch is intercepted. Cleanup is idempotent and removes both listeners.

  2. files/webviews/src/agent-session/acpmux/wheelGlide.ts:28-33 — Severity: none. The WebKit reasoning is sound: passive observation permits asynchronous trackpad scrolling after the cancelling listener is removed. Other relevant wheel listeners are passive.

  3. files/webviews/src/agent-session/acpmux/App.tsx:3967-3999 — Severity: none. The six viewport threshold provides the intended lead time. Existing per-page key gating and settle/recheck logic prevent the threshold change from introducing ordinary request storms, and reopened-chat seeking remains separately handled.

  4. files/webviews/test/wheel-glide-motion.test.ts:257-277; files/webviews/test/transcript-older-scroll.test.ts:80-108 — Severity: none. The wheel test fails on the base because the cancelling listener remains installed. The transcript test exercises real WebKit wheel input and fails the base’s two-viewport behavior through its four-viewport minimum assertion.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Red checks at ac90c11, classified before landing:

  • web / react-apps-base-check (1): its only head-only red is test/accent-glass-invariants.test.ts, a Vite warm-up timeout (warmDevServer: /gallery/frame.html still cold after 100000 ms) under the shard's load. Alone at this head it passes, 2472/2472 stages with 0 findings: https://github.com/manaflow-ai/cmux/actions/runs/40849591568. Nothing in this PR touches the gallery or styles.
  • release-notary: fails the same way on feat-cmux-next (test_the_repository_highlights_validate, run 40841929807).
  • cmux-next checks: No new SwiftUI and Phone lane field allowlist, Swift and phone checks. This PR changes only webviews/; the same two are red on feat-cmux-next run 40834849145.
  • Guard status / Web status are wrappers of the above.

@teamleaderleo
teamleaderleo merged commit 822129b into feat-cmux-next Oct 29, 2026
244 of 280 checks passed
@teamleaderleo
teamleaderleo deleted the scroll-page-prefetch branch October 29, 2026 09:37
@github-actions

Copy link
Copy Markdown
Contributor

The feat-cmux-next push run https://github.com/manaflow-ai/cmux/actions/runs/40851476637 at c89a975 failed: cmux-next checks (god files, concurrency, crash safety, l10n).
Those jobs last passed at ff35fb4. This pull request is one of 5 merged since: #27344, #27290, #27263, #27311, #27273.
If the failure is in your change, fix forward on feat-cmux-next. Pull requests run only the tiers their paths reach (docs/ci/cmux-next-tiers.md); label a batch PR full-ci to run them all before merging.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

This branch was previously deployed

1 inactive deployment
ci-routing-agent — ac90c11d Deployed Oct 29, 2026 by teamleaderleo via cmux-tui tree preflight #75699
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant