fix(desktop): stabilize native transcript scrolling / 修复聊天区原生滚动跳页与卡顿 (#8264)

* fix(desktop): stop trackpad scroll stalls in the chat transcript

Problem: On macOS, two-finger scrolling the chat content area felt sticky
and jumpy—especially over markdown tables—because nested overflow
containers latched the trackpad and virtualizer/stream rewrites fought
native inertia mid-gesture.

Root cause: CSS overflow-x:auto promotes overflow-y to auto, so ordinary
tables/code blocks became vertical scroll ports; virtualizer height
compensation and stream tail-follow could still write scrollTop while the
user was gesturing.

Fix: Add a short user-gesture lock that blocks compensating scroll writes,
promote edge/non-scrollable nested wheels to the transcript with latching
handoff, keep small tables and code blocks out of nested vertical scroll,
and mark large virtual tables for handoff.

Verification: npx tsx scroll-manager, transcript-scroll-session,
nested-scroll-handoff, markdown-table-virtual, typography-overflow-contract,
and transcript-virtualization tests.

* fix(desktop): fold large tables and idle-remeasure after scroll

Problem: Large markdown tables still introduced nested vertical scroll
ports by default, and virtualizer remeasure could jump after a trackpad
gesture settled.

Root cause: Oversized tables mounted a max-height virtual scroller
immediately, and height compensation was deferred only mid-gesture, not
batched once after the hold window.

Fix: Collapse tables above the virtual threshold to a 12-row document-flow
preview with Expand all / Collapse; expanded tables keep the nested
virtual scroller with handoff. After gesture idle, measure once and
reconcile the reading anchor.

Verification: npx tsx markdown-table-virtual, scroll-manager,
transcript-scroll-session, and nested-scroll-handoff tests.

* fix(desktop): stabilize native transcript scrolling

Problem:
Long variable-height transcripts could stall or jump while trackpad inertia, native scrollbar input, or Windows middle-button auto-scroll overlapped virtualizer and resize compensation. Creation scrollbar sensitivity could also change while rows mounted.

Root cause:
Scroll ownership only covered wheel, touch, and keyboard intent; treated unowned native scroll events ambiguously; remeasured the entire virtualizer after every idle gesture; and recomputed custom-scrollbar geometry during a drag. A stale scrollend could also settle a newer gesture.

Fix:
Model native input sessions explicitly, arbitrate controller-owned scroll events, normalize wheel delta modes, guard stale scrollend, freeze custom-scrollbar drag geometry, and measure only real layout invalidations with anchor reconciliation. Split interaction and scrollbar concerns out of Transcript and add real-browser stability coverage.

Verification:
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:transcript-browser
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:motion
- pnpm --dir desktop/frontend build
- go run ./tools/repolint

* fix(desktop): latch manual transcript scroll intent

Problem:
A short upward wheel, trackpad, native scrollbar, or Creation scrollbar movement could remain inside the 80px near-bottom band. After the gesture quiet window, streaming output could therefore re-enable tail-follow and pull the viewport back down.

Root cause:
The scroll manager treated geometric proximity as user intent and recomputed stick mode from the near-bottom threshold after every native scroll event.

Fix:
Latch explicit user detachment across gesture settlement, keep resize and stream repins disabled while detached, and restore tail-follow only at the physical bottom or through an explicit bottom/reset action. Apply the same contract to the Creation scrollbar.

Verification:
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:transcript-browser
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:motion
- pnpm --dir desktop/frontend build
- go run ./tools/repolint

* fix(desktop): preserve tail follow across scroll ownership

Problem:
- Nested downward handoff at the physical bottom detached streaming tail-follow.
- Passive tail writes rejected during an active gesture were dropped, so final token and row-size growth could leave the viewport behind.

Root cause:
- Nested handoff discarded wheel direction and always released auto-scroll.
- Gesture arbitration had no generation-aware replay for blocked passive owners.

Fix:
- Propagate normalized nested deltas and release only for upward or detached reading intent.
- Replay blocked stream, resize, footer, and row-size repins once after gesture idle using the current scrollHeight.
- Cancel deferred work on manual intent, selection, explicit navigation, and tab generation changes.
- Adapt #8291's row-size ownership, pinned measurement rule, and stream-end fallback into the unified gesture controller.
- Add deterministic and real Chromium regression coverage.

Verification:
- pnpm test:transcript
- pnpm test:transcript-browser
- pnpm test:typecheck
- pnpm test:motion
- pnpm build
- go run ./tools/repolint

Co-authored-by: LI Xinyu <[email protected]>

---------

Co-authored-by: LI Xinyu <[email protected]>
This commit is contained in:
SivanColaandLI Xinyu authored and GitHub committed 2026-08-11 11:59:43 +08:00
1 parent 85c7c0e80d
commit 2760c09466
26 files changed
+2208 -303

No files matched your search

@@ -0,0 +1,203 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
? path.join(frontendDir, ".pw-browsers")
: process.env.PLAYWRIGHT_BROWSERS_PATH;
const { chromium } = await import("playwright");
const port = Number(process.env.REASONIX_TRANSCRIPT_SCROLL_PORT ?? 4619);
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
function assert(condition, message) {
if (!condition) throw new Error(message);
process.stdout.write(` PASS ${message}\n`);
}
async function waitForServer() {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const ready = await new Promise((resolve) => {
const request = http.get(url, (response) => {
response.resume();
resolve((response.statusCode ?? 500) < 500);
});
request.on("error", () => resolve(false));
});
if (ready) return;
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error("transcript scroll preview did not become ready");
}
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
cwd: frontendDir,
stdio: "ignore",
});
let browser;
try {
await waitForServer();
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go"), undefined, { timeout: 30_000 });
await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return;
transcript.scrollTop = Math.max(0, transcript.scrollHeight - transcript.clientHeight * 2);
window.__scrollWrites = [];
window.__scrollGestureTrace = [];
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => window.__scrollWrites.push({ owner, top });
new MutationObserver(() => {
window.__scrollGestureTrace.push(transcript.dataset.scrollGesture ?? "idle");
}).observe(transcript, { attributes: true, attributeFilter: ["data-scroll-gesture"] });
});
const box = await page.locator(".transcript").boundingBox();
assert(box != null, "bench exposes the transcript viewport");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -420);
await page.waitForFunction(() => window.__scrollGestureTrace?.includes("wheel"), undefined, { timeout: 3_000 });
assert(true, "real Chromium wheel input enters the user scroll session");
const midGesture = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
window.__scrollWrites = [];
transcript.dispatchEvent(new WheelEvent("wheel", { deltaY: -240, bubbles: true, cancelable: true }));
transcript.scrollTop = Math.max(0, transcript.scrollTop - 240);
transcript.dispatchEvent(new Event("scroll"));
const row = [...transcript.querySelectorAll(".transcript__row")].find((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.bottom > transcript.getBoundingClientRect().top;
});
if (row instanceof HTMLElement) row.style.paddingTop = "180px";
return { gesture: transcript.dataset.scrollGesture, top: transcript.scrollTop, rowKey: row?.dataset.rowKey ?? null };
});
assert(midGesture?.gesture === "wheel", "variable-height mutation occurs while wheel ownership is active");
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const during = await page.evaluate(() => ({
writes: window.__scrollWrites ?? [],
gesture: document.querySelector(".transcript")?.dataset.scrollGesture,
}));
assert(during.writes.every((write) => !["virtualizer", "stream", "container-resize", "footer-resize", "row-size"].includes(write.owner)),
`compensating owners stay silent during variable-height scroll (${JSON.stringify(during.writes)})`);
await page.evaluate(() => document.querySelector(".transcript")?.dispatchEvent(new Event("scrollend")));
await page.waitForFunction(() => !document.querySelector(".transcript")?.dataset.scrollGesture);
const settledTop = await page.locator(".transcript").evaluate((element) => element.scrollTop);
await page.waitForTimeout(260);
const lateTop = await page.locator(".transcript").evaluate((element) => element.scrollTop);
assert(Math.abs(lateTop - settledTop) <= 1, `scrollend has no delayed full-measure jump (${settledTop} → ${lateTop})`);
const middle = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return null;
window.__scrollWrites = [];
transcript.dispatchEvent(new PointerEvent("pointerdown", { button: 1, buttons: 4, pointerId: 9, bubbles: true }));
const started = transcript.dataset.scrollGesture;
transcript.scrollTop = Math.max(0, transcript.scrollTop - 160);
transcript.dispatchEvent(new Event("scroll"));
return { started, continued: transcript.dataset.scrollGesture, top: transcript.scrollTop };
});
assert(middle?.started === "middle-button", "middle-button activation owns Windows auto-scroll");
assert(middle?.continued === "middle-button", "unowned native scroll refreshes middle-button ownership");
const middleWrites = await page.evaluate(() => window.__scrollWrites ?? []);
assert(middleWrites.length === 0, "middle-button auto-scroll admits no compensating programmatic writes");
await page.evaluate(() => document.querySelector(".transcript")?.dispatchEvent(new Event("scrollend")));
await page.waitForFunction(() => !document.querySelector(".transcript")?.dataset.scrollGesture);
assert(true, "middle-button session terminates on native scrollend");
// Reproduce the subtle tail-follow race: a short upward wheel stays inside
// the 80px near-bottom band, then the gesture settles before more content
// arrives. User intent must remain manual across that quiet boundary.
const jumpBottom = page.locator(".transcript__jump-bottom");
await jumpBottom.click();
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
return transcript && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 0.5;
});
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -24);
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
if (!transcript) return false;
const distance = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight;
return distance > 0.5 && distance < 80 && transcript.dataset.scrollGesture === "wheel";
});
await page.waitForTimeout(64);
await page.evaluate(() => document.querySelector(".transcript")?.dispatchEvent(new Event("scrollend")));
await page.waitForFunction(() => !document.querySelector(".transcript")?.dataset.scrollGesture);
const shortUpward = await page.locator(".transcript").evaluate((element) => ({
distance: element.scrollHeight - element.scrollTop - element.clientHeight,
mode: element.dataset.scrollMode,
top: element.scrollTop,
}));
assert(shortUpward.mode === "manual", `short upward wheel remains manual inside the bottom threshold (${JSON.stringify(shortUpward)})`);
await page.waitForTimeout(260);
const shortUpwardSettled = await page.locator(".transcript").evaluate((element) => ({
mode: element.dataset.scrollMode,
top: element.scrollTop,
}));
assert(shortUpwardSettled.mode === "manual" && Math.abs(shortUpwardSettled.top - shortUpward.top) <= 1,
`quiet-window expiry does not re-pin the short upward gesture (${JSON.stringify(shortUpwardSettled)})`);
await page.mouse.wheel(0, 120);
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
return transcript
&& transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 0.5
&& transcript.dataset.scrollMode === "tail-follow";
});
assert(true, "user wheel back to the physical bottom explicitly restores tail-follow");
// A harmless downward wheel at the physical bottom still freezes passive
// writers. If the final row grows during that lock, the repin is deferred and
// replayed once after idle using the latest scrollHeight.
const harmlessDown = await page.evaluate(() => {
const transcript = document.querySelector(".transcript");
const rows = transcript ? [...transcript.querySelectorAll(".transcript__row")] : [];
const tailRow = rows.at(-1);
if (!(transcript instanceof HTMLElement) || !(tailRow instanceof HTMLElement)) return null;
transcript.scrollTop = transcript.scrollHeight;
window.__scrollWrites = [];
transcript.dispatchEvent(new WheelEvent("wheel", { deltaY: 48, bubbles: true, cancelable: true }));
tailRow.style.paddingBottom = `${Number.parseFloat(tailRow.style.paddingBottom || "0") + 160}px`;
return { gesture: transcript.dataset.scrollGesture, mode: transcript.dataset.scrollMode };
});
assert(harmlessDown?.gesture === "wheel" && harmlessDown.mode === "tail-follow",
`wheel-down at the physical bottom preserves tail-follow (${JSON.stringify(harmlessDown)})`);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))));
const harmlessDuring = await page.evaluate(() => window.__scrollWrites ?? []);
assert(harmlessDuring.every((write) => !["virtualizer", "stream", "container-resize", "footer-resize", "row-size"].includes(write.owner)),
`tail growth stays write-free during the downward gesture (${JSON.stringify(harmlessDuring)})`);
await page.waitForTimeout(64);
await page.evaluate(() => document.querySelector(".transcript")?.dispatchEvent(new Event("scrollend")));
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
return transcript
&& !transcript.dataset.scrollGesture
&& transcript.dataset.scrollMode === "tail-follow"
&& transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 0.5;
});
const harmlessSettled = await page.locator(".transcript").evaluate((element) => ({
distance: element.scrollHeight - element.scrollTop - element.clientHeight,
mode: element.dataset.scrollMode,
}));
assert(harmlessSettled.distance <= 0.5 && harmlessSettled.mode === "tail-follow",
`deferred tail growth replays once after gesture idle (${JSON.stringify(harmlessSettled)})`);
process.stdout.write("\ntranscript scroll stability browser gate passed\n");
} finally {
await browser?.close();
preview.kill("SIGTERM");
}
+2 -2
View File
@@ -19,8 +19,8 @@
"test:workspace": "tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx",
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-selection-runtime.test.ts && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx",
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs",
"test:transcript": "tsx src/__tests__/transcript-scroll-controller.test.ts && tsx src/__tests__/transcript-scroll-session.test.ts && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/transcript-measurement-invalidation.test.tsx && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-runtime.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx",
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
"test": "node scripts/test-todo-visibility.mjs && node scripts/run-tests.mjs",
@@ -80,7 +80,9 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
.reduce((total, path) => total + statSync(path).size, 0);
// Native Web Animations and frame-batched scrolling avoid an eager animation
// runtime. Goal request observability plus transcript scroll arbitration,
// logical selection state/DOM adapters, and measurement invalidation add small
// always-available contracts; keep raw allowance tight while gzip stays flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_240 * 1024);
// logical selection state/DOM adapters, native input-session ownership, and
// measurement invalidation and deferred tail replay add small always-available
// contracts. The expanded contract remains tightly bounded while the gzip
// startup budgets stay flat.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_246 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
@@ -60,6 +60,12 @@ if (motionScript.includes("transcript-virtualization.test.tsx")) {
const transcriptScript = packageJSON.scripts?.["test:transcript"] ?? "";
for (const required of [
"transcript-scroll-session.test.ts",
"nested-scroll-handoff.test.ts",
"creation-transcript-scrollbar.test.ts",
"transcript-measurement-invalidation.test.tsx",
"markdown-table-virtual.test.tsx",
"typography-overflow-contract.test.ts",
"transcript-selection-runtime.test.ts",
"scroll-manager.test.tsx",
"transcript-selection-retention.test.tsx",
@@ -89,5 +95,10 @@ if (!jobBody("desktop", "desktop-macos").includes(transcriptBrowserCommand) || t
if (!jobBody("desktop", "desktop-macos").includes("PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install")) {
throw new Error("motion-ci-contract: Chromium must install into the path used by frontend browser tests");
}
for (const required of ["transcript-selection.mjs", "transcript-scroll-stability.mjs"]) {
if (!packageJSON.scripts?.["test:transcript-browser"]?.includes(required)) {
throw new Error(`motion-ci-contract: test:transcript-browser must include ${required}`);
}
}
console.log("motion-ci-contract: required jobs run focused native motion gates, Linux owns transcript virtualization, and Windows runs the real WebView2 smoke");
+7
View File
@@ -33,7 +33,14 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
"settings-responsive-layout.test.ts": "test:settings-responsive",
"transcript-virtualization.test.tsx": "test:transcript",
"transcript-selection-runtime.test.ts": "test:transcript",
"transcript-scroll-controller.test.ts": "test:transcript",
"transcript-scroll-session.test.ts": "test:transcript",
"scroll-manager.test.tsx": "test:transcript",
"nested-scroll-handoff.test.ts": "test:transcript",
"creation-transcript-scrollbar.test.ts": "test:transcript",
"transcript-measurement-invalidation.test.tsx": "test:transcript",
"markdown-table-virtual.test.tsx": "test:transcript",
"typography-overflow-contract.test.ts": "test:transcript",
"transcript-selection-retention.test.tsx": "test:transcript",
"composer-menu-viewport.test.ts": "test:composer-menu-viewport",
"virtual-menu-identity.test.tsx": "test:composer-menu-viewport",
@@ -0,0 +1,50 @@
// Run: tsx src/__tests__/creation-transcript-scrollbar.test.ts
import {
mapFrozenScrollbarDrag,
readCreationScrollbarGeometry,
} from "../lib/useCreationTranscriptScrollbar";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\ncreation transcript scrollbar");
const initial = readCreationScrollbarGeometry(800, 4_000);
if (!initial) throw new Error("expected scrollable geometry");
eq(initial.overflow, 3_200, "geometry records the pointerdown overflow");
eq(initial.thumbHeight, 160, "geometry derives the thumb from the pointerdown layout");
eq(initial.maxThumbTop, 640, "geometry records the pointerdown thumb track");
const drag = {
startY: 100,
startThumbTop: 320,
overflow: initial.overflow,
maxThumbTop: initial.maxThumbTop,
};
const moved = mapFrozenScrollbarDrag(drag, 200);
eq(moved.thumbTop, 420, "drag follows pointer pixels on the frozen track");
eq(moved.scrollTop, 2_100, "drag maps through the frozen pointerdown overflow");
// Content can grow while virtual rows mount. The active drag must keep using
// the pointerdown geometry rather than changing sensitivity under the pointer.
const grown = readCreationScrollbarGeometry(800, 8_000);
if (!grown) throw new Error("expected grown scrollable geometry");
eq(grown.overflow, 7_200, "content growth would otherwise change the live mapping");
eq(mapFrozenScrollbarDrag(drag, 200).scrollTop, 2_100, "content growth cannot remap an active drag");
eq(mapFrozenScrollbarDrag(drag, -1_000).thumbTop, 0, "drag clamps at the top");
eq(mapFrozenScrollbarDrag(drag, 2_000).thumbTop, 640, "drag clamps at the bottom");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -1,15 +1,19 @@
// Run: tsx src/__tests__/markdown-table-virtual.test.tsx
//
// Oversized markdown tables (> MARKDOWN_TABLE_VIRTUAL_MIN_ROWS body rows)
// render through a row-virtualized table: the header stays intact and the
// mounted row count stays bounded by the viewport, while small tables render
// exactly as before. Content fidelity: every row is in the virtual model.
// Large markdown tables (> MARKDOWN_TABLE_VIRTUAL_MIN_ROWS) default to an
// in-flow preview + expand control. Expanding mounts a row-virtualized
// nested scroller. Small tables stay plain document-flow markup.
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { createComponents } from "../components/markdownComponents";
import { MarkdownTable, MARKDOWN_TABLE_VIRTUAL_MIN_ROWS, VirtualMarkdownSourceTable } from "../components/MarkdownTable";
import {
MarkdownTable,
MARKDOWN_TABLE_PREVIEW_ROWS,
MARKDOWN_TABLE_VIRTUAL_MIN_ROWS,
VirtualMarkdownSourceTable,
} from "../components/MarkdownTable";
import { hastBlockToJsx } from "../lib/hastJsx";
import { parseMarkdownToBlocks } from "../lib/markdownPipeline";
@@ -82,7 +86,7 @@ if (!rootEl) throw new Error("missing root");
console.log("\nmarkdown table virtualization");
// ── oversized table: bounded mounted rows, intact header ─────────────────────
// ── oversized table: collapsed preview by default ────────────────────────────
{
const rowCount = MARKDOWN_TABLE_VIRTUAL_MIN_ROWS * 3; // 150 rows
const root = createRoot(rootEl);
@@ -101,17 +105,43 @@ console.log("\nmarkdown table virtualization");
);
});
await flush();
ok(rootEl.querySelector(".md-table-fold"), "oversized table uses the fold wrapper");
ok(!rootEl.querySelector(".md-table-virtual"), "collapsed large tables do not mount a nested scroller");
eq(
rootEl.querySelectorAll(".md-table-scroll tbody tr").length,
MARKDOWN_TABLE_PREVIEW_ROWS,
"collapsed preview mounts only the first preview rows",
);
const toggle = rootEl.querySelector<HTMLButtonElement>(".md-table-fold__toggle");
ok(toggle, "expand control is present");
ok(toggle?.textContent?.includes(String(rowCount)), "expand control shows total row count");
await act(async () => {
toggle?.click();
});
await flush();
const scroller = rootEl.querySelector(".md-table-virtual");
ok(scroller, "oversized table renders inside the virtual scroll container");
ok(scroller, "expanding mounts the virtual scroll container");
const headerCells = rootEl.querySelectorAll(".md-table-virtual thead th");
eq(headerCells.length, 3, "table header stays intact");
eq(headerCells.length, 3, "table header stays intact after expand");
eq(headerCells[0]?.textContent, "name", "header content is preserved");
const mountedRows = rootEl.querySelectorAll(".md-table-virtual tbody tr");
ok(mountedRows.length > 0, "some body rows mount");
ok(mountedRows.length > 0, "some body rows mount after expand");
ok(mountedRows.length < rowCount, `mounted rows are bounded (${mountedRows.length} < ${rowCount})`);
ok(mountedRows.length <= 60, "mounted row count tracks the viewport, not the model");
const firstRow = rootEl.querySelector(".md-table-virtual tbody tr");
eq(firstRow?.textContent, "row 00note 0", "the first row renders its cells");
await act(async () => {
rootEl.querySelector<HTMLButtonElement>(".md-table-fold__toggle")?.click();
});
await flush();
ok(!rootEl.querySelector(".md-table-virtual"), "collapse removes the nested scroller");
eq(
rootEl.querySelectorAll(".md-table-scroll tbody tr").length,
MARKDOWN_TABLE_PREVIEW_ROWS,
"collapse restores the preview row count",
);
await act(async () => root.unmount());
}
@@ -133,6 +163,7 @@ console.log("\nmarkdown table virtualization");
});
await flush();
ok(!rootEl.querySelector(".md-table-virtual"), "small tables render without virtualization");
ok(!rootEl.querySelector(".md-table-fold"), "small tables do not show a fold control");
eq(rootEl.querySelectorAll("tbody tr").length, 2, "small tables mount every row");
await act(async () => root.unmount());
}
@@ -155,7 +186,18 @@ console.log("\nmarkdown table virtualization");
);
});
await flush();
ok(rootEl.querySelector(".md-table-virtual"), "markdown tables route through the virtual table override");
ok(rootEl.querySelector(".md-table-fold"), "markdown source tables start collapsed");
ok(!rootEl.querySelector(".md-table-virtual"), "collapsed source tables avoid nested scroll");
eq(
rootEl.querySelectorAll(".md-table-scroll tbody tr").length,
MARKDOWN_TABLE_PREVIEW_ROWS,
"source table preview is bounded",
);
await act(async () => {
rootEl.querySelector<HTMLButtonElement>(".md-table-fold__toggle")?.click();
});
await flush();
ok(rootEl.querySelector(".md-table-virtual"), "expanding source tables mounts virtualization");
const mounted = rootEl.querySelectorAll(".md-table-virtual tbody tr").length;
ok(mounted > 0 && mounted < 120, `markdown table body is virtualized (${mounted} < 120)`);
eq(rootEl.querySelector(".md-table-virtual thead th")?.textContent, "name", "markdown table header renders");
@@ -173,6 +215,12 @@ console.log("\nmarkdown table virtualization");
root.render(<div className="md">{hastBlockToJsx(blocks[0], createComponents(false))}</div>);
});
await flush();
// Semantic path still goes through MarkdownTable via components map when expanded from hast.
ok(
rootEl.querySelector("tbody strong")?.textContent === "row 0"
|| rootEl.querySelector(".md-table-fold") != null,
"complex table preserves inline Markdown or fold wrapper",
);
eq(rootEl.querySelector("tbody strong")?.textContent, "row 0", "complex table preserves inline Markdown");
await act(async () => root.unmount());
}
@@ -0,0 +1,185 @@
// Run: tsx src/__tests__/nested-scroll-handoff.test.ts
import { JSDOM } from "jsdom";
import {
attachNestedScrollHandoff,
canElementScrollVertically,
normalizeWheelDelta,
shouldHandoffVerticalWheel,
} from "../lib/nestedScrollHandoff";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
console.log("\nnested scroll handoff");
const dom = new JSDOM(`<!doctype html><html><body>
<div id="parent" style="height:200px;overflow-y:auto">
<div id="nested" data-nested-scroll style="height:100px;overflow-y:auto">
<div id="inner" style="height:400px">cell</div>
</div>
<div id="table-wrap" class="md-table-scroll" style="height:80px;overflow-x:auto;overflow-y:hidden">
<div id="table-cell">wide table cell</div>
</div>
</div>
</body></html>`, { pretendToBeVisual: true });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.WheelEvent = dom.window.WheelEvent;
const parent = document.getElementById("parent") as HTMLElement;
const nested = document.getElementById("nested") as HTMLElement;
const inner = document.getElementById("inner") as HTMLElement;
const tableWrap = document.getElementById("table-wrap") as HTMLElement;
const tableCell = document.getElementById("table-cell") as HTMLElement;
Object.defineProperty(nested, "clientHeight", { configurable: true, value: 100 });
Object.defineProperty(nested, "scrollHeight", { configurable: true, value: 400 });
let nestedTop = 0;
Object.defineProperty(nested, "scrollTop", {
configurable: true,
get: () => nestedTop,
set: (v: number) => { nestedTop = v; },
});
Object.defineProperty(tableWrap, "clientHeight", { configurable: true, value: 80 });
Object.defineProperty(tableWrap, "scrollHeight", { configurable: true, value: 80 });
Object.defineProperty(tableWrap, "scrollTop", { configurable: true, value: 0 });
Object.defineProperty(parent, "clientHeight", { configurable: true, value: 200 });
Object.defineProperty(parent, "scrollHeight", { configurable: true, value: 2000 });
let parentTop = 100;
Object.defineProperty(parent, "scrollTop", {
configurable: true,
get: () => parentTop,
set: (v: number) => { parentTop = v; },
});
// Mock computed styles for overflow detection.
const styleMap = new Map<Element, { overflowX: string; overflowY: string }>([
[nested, { overflowX: "auto", overflowY: "auto" }],
[tableWrap, { overflowX: "auto", overflowY: "hidden" }],
[parent, { overflowX: "hidden", overflowY: "auto" }],
[inner, { overflowX: "visible", overflowY: "visible" }],
[tableCell, { overflowX: "visible", overflowY: "visible" }],
]);
dom.window.getComputedStyle = ((el: Element) => {
const s = styleMap.get(el) ?? { overflowX: "visible", overflowY: "visible" };
return s as unknown as CSSStyleDeclaration;
}) as typeof getComputedStyle;
globalThis.getComputedStyle = dom.window.getComputedStyle;
nestedTop = 50;
ok(canElementScrollVertically(nested, -20), "nested can scroll up from mid-body");
ok(canElementScrollVertically(nested, 20), "nested can scroll down from mid-body");
nestedTop = 0;
ok(!canElementScrollVertically(nested, -20), "nested cannot scroll up at top");
nestedTop = 300;
ok(!canElementScrollVertically(nested, 20), "nested cannot scroll down at bottom");
ok(!canElementScrollVertically(tableWrap, 20), "horizontal-only wrapper cannot scroll vertically");
nestedTop = 0;
ok(
shouldHandoffVerticalWheel(inner, parent, -40),
"wheel-up at nested top should handoff to parent",
);
nestedTop = 50;
ok(
!shouldHandoffVerticalWheel(inner, parent, -40),
"wheel-up mid nested should not handoff",
);
ok(
shouldHandoffVerticalWheel(tableCell, parent, 40),
"wheel over horizontal-only table wrapper handoffs vertical to parent",
);
// Integration: attach listener and dispatch wheels.
nestedTop = 0;
parentTop = 100;
let intents = 0;
const handedOffDeltas: number[] = [];
let clock = 1_000;
const handoff = attachNestedScrollHandoff({
parent,
onParentScrollIntent: (deltaY) => {
intents += 1;
handedOffDeltas.push(deltaY);
},
latchHoldMs: 200,
now: () => clock,
});
const wheelAt = (target: Element, deltaY: number, deltaMode: number = dom.window.WheelEvent.DOM_DELTA_PIXEL) => {
const event = new dom.window.WheelEvent("wheel", {
deltaY,
deltaX: 0,
deltaMode,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event;
};
const edgeUp = wheelAt(inner, -40);
ok(edgeUp.defaultPrevented, "edge wheel is preventDefaulted");
eq(parentTop, 60, "edge wheel promotes deltaY onto parent scrollTop");
ok(intents >= 1, "edge handoff notifies parent scroll intent");
eq(handedOffDeltas[0], -40, "edge handoff reports normalized direction to the parent controller");
// After an edge handoff, latch keeps driving the parent even if nested could scroll.
clock += 10; // still inside latchHoldMs
const afterLatch = parentTop;
nestedTop = 50;
const latched = wheelAt(inner, -30);
ok(latched.defaultPrevented, "latched gesture continues on parent");
eq(parentTop, afterLatch - 30, "latched wheel keeps applying to parent");
eq(handedOffDeltas[1], -30, "latched handoff keeps reporting normalized direction");
// Once the latch expires, mid-body nested scroll is left to the browser.
clock += 500;
const midTopBefore = parentTop;
nestedTop = 50;
const mid = wheelAt(inner, -20);
ok(!mid.defaultPrevented, "mid nested wheel is left to the browser after latch expires");
eq(parentTop, midTopBefore, "mid nested wheel does not move parent after latch expires");
const lineDelta = normalizeWheelDelta({ deltaX: 0, deltaY: 2, deltaMode: dom.window.WheelEvent.DOM_DELTA_LINE }, parent);
eq(lineDelta.y, 32, "line-mode wheel deltas use a stable pixel fallback");
const pageDelta = normalizeWheelDelta({ deltaX: 0, deltaY: 1, deltaMode: dom.window.WheelEvent.DOM_DELTA_PAGE }, parent);
eq(pageDelta.y, 200, "page-mode wheel deltas use the transcript viewport height");
clock += 500;
nestedTop = 0;
parentTop = 500;
wheelAt(inner, -2, dom.window.WheelEvent.DOM_DELTA_LINE);
eq(parentTop, 468, "line-mode handoff applies normalized pixels instead of raw lines");
clock += 500;
parentTop = 500;
wheelAt(inner, -1, dom.window.WheelEvent.DOM_DELTA_PAGE);
eq(parentTop, 300, "page-mode handoff applies one viewport page");
handoff.detach();
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -79,6 +79,11 @@ Object.defineProperty(transcript, "scrollTop", {
get: () => scrollTop,
set: (value) => { scrollTop = value; },
});
transcript.scrollTo = ((options: ScrollToOptions) => {
if (typeof options.top === "number") {
scrollTop = Math.max(0, Math.min(options.top, transcript.scrollHeight - transcript.clientHeight));
}
}) as typeof transcript.scrollTo;
await act(async () => {
api?.onScroll();
@@ -101,6 +106,230 @@ if (api.stick.current) {
}
eq(scrollTop, 900, "a queued streaming auto-scroll would not yank after manual wheel intent");
// A short upward move remains inside the ordinary near-bottom threshold. It
// must still latch manual mode after the gesture settles; otherwise the next
// streaming token can pull the reader back to the bottom.
scrollTop = 860;
await act(async () => {
api!.onScroll();
});
eq(api!.stick.current, false, "short upward scroll keeps tail-follow disengaged inside the bottom threshold");
eq(api!.modeRef.current, "manual", "short upward scroll preserves manual mode");
await act(async () => {
api!.gestureLastActivityRef.current -= 100;
api!.onScrollEnd();
api!.scrollToBottom(false, "stream");
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
eq(scrollTop, 860, "streaming cannot re-pin after a settled short upward gesture");
// User-owned downward scrolling may deliberately opt back into tail-follow,
// but only after reaching the physical bottom rather than merely its 80px
// proximity band.
scrollTop = 899;
await act(async () => {
api!.onScroll();
});
eq(api!.stick.current, false, "manual latch remains one pixel above the physical bottom");
scrollTop = 900;
await act(async () => {
api!.onScroll();
});
eq(api!.stick.current, true, "user scroll to the physical bottom restores tail-follow");
eq(api!.modeRef.current, "tail-follow", "physical bottom clears manual mode");
// Nested tables/code blocks hand their edge wheel to the transcript. A
// downward handoff at the physical bottom is harmless: it still owns the
// gesture (so compensating writers stay frozen), but must not detach the
// transcript from streaming tail-follow.
await act(async () => {
const released = api!.onNestedScrollIntent(48);
eq(released, false, "nested wheel-down at the bottom does not release tail-follow");
});
eq(api!.stick.current, true, "nested wheel-down at the bottom preserves the bottom pin");
eq(api!.modeRef.current, "tail-follow", "nested wheel-down at the bottom preserves tail-follow mode");
// If the final token grows the transcript while that harmless gesture lock is
// active, the rejected passive write must be replayed once at gesture idle.
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1040 });
await act(async () => {
const wrote = api!.writeOffset("stream", transcript.scrollHeight);
eq(wrote, false, "stream write is deferred while the harmless nested gesture is active");
api!.gestureLastActivityRef.current -= 100;
api!.onScrollEnd();
});
eq(scrollTop, 940, "deferred tail-follow replays to the current bottom after gesture idle");
// Row measurements use the same deferred path. This covers async Markdown,
// image and code-block growth that lands after the token frame itself.
await act(async () => {
api!.onWheelIntent({ deltaX: 0, deltaY: 48 } as React.WheelEvent<HTMLElement>);
});
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1080 });
await act(async () => {
api!.scheduleRepinIfWasPinned(0, "row-size");
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
});
eq(scrollTop, 940, "row-size repin stays silent during the active gesture");
await act(async () => {
api!.gestureLastActivityRef.current -= 100;
api!.onScrollEnd();
});
eq(scrollTop, 980, "row-size repin replays once after gesture idle");
// A real upward intent after a deferred passive write cancels the replay. The
// reader must remain in control even when no later token arrives.
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1000 });
scrollTop = 900;
await act(async () => {
api!.onWheelIntent({ deltaX: 0, deltaY: 48 } as React.WheelEvent<HTMLElement>);
});
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1040 });
await act(async () => {
eq(api!.writeOffset("stream", transcript.scrollHeight), false, "passive tail write queues during downward gesture");
api!.onWheelIntent({ deltaX: 0, deltaY: -48 } as React.WheelEvent<HTMLElement>);
api!.gestureLastActivityRef.current -= 100;
api!.onScrollEnd();
});
eq(scrollTop, 900, "upward intent cancels a queued tail-follow replay");
eq(api!.modeRef.current, "manual", "upward intent keeps manual ownership after idle");
// Deferred work is scoped to the transcript generation. Switching tabs must
// discard the previous tab's pending replay even if its idle callback arrives.
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1000 });
scrollTop = 900;
await act(async () => {
api!.setMode("tail-follow", "deferred-generation-test");
api!.stick.current = true;
api!.onWheelIntent({ deltaX: 0, deltaY: 48 } as React.WheelEvent<HTMLElement>);
});
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1040 });
await act(async () => {
eq(api!.writeOffset("stream", transcript.scrollHeight), false, "old generation queues a passive tail write");
api!.resetGeneration("next-tab", 2);
});
eq(scrollTop, 900, "tab generation reset discards the previous transcript's replay");
Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1000 });
scrollTop = 900;
// Scrollbar drags do not emit wheel intent. The first unowned scroll away from
// the physical bottom must establish the same manual latch for mouse users.
scrollTop = 870;
await act(async () => {
api!.onScroll();
});
eq(api!.stick.current, false, "short native scrollbar drag disengages tail-follow without a wheel event");
eq(api!.modeRef.current, "manual", "native scrollbar drag enters manual mode");
scrollTop = 900;
await act(async () => {
api!.onScroll();
});
eq(api!.stick.current, true, "scrollbar drag back to the physical bottom restores tail-follow");
// Creation's custom scrollbar writes through the controller, so its scroll
// events are intentionally classified as programmatic. Finishing that drag
// must still apply the same user-intent latch.
await act(async () => {
api!.setMode("programmatic", "custom-scrollbar-test");
api!.writeOffset("custom-scrollbar", 870);
api!.onScroll();
api!.finishProgrammaticScroll();
});
eq(api!.stick.current, false, "custom scrollbar near-bottom drag remains manual after pointerup");
eq(api!.modeRef.current, "manual", "custom scrollbar finish preserves the manual latch");
await act(async () => {
api!.setMode("programmatic", "custom-scrollbar-bottom-test");
api!.writeOffset("custom-scrollbar", 900);
api!.onScroll();
api!.finishProgrammaticScroll();
});
eq(api!.stick.current, true, "custom scrollbar drag to the physical bottom restores tail-follow");
// Gesture lock: virtualizer/stream must not rewrite scrollTop mid-gesture.
const writes: Array<[string, number]> = [];
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => {
writes.push([owner, top]);
};
scrollTop = 400;
await act(async () => {
api!.stick.current = false;
api!.setMode("manual", "test");
api!.markUserGesture();
const virtualizerWrote = api!.writeOffset("virtualizer", 120);
const streamWrote = api!.writeOffset("stream", 900);
eq(virtualizerWrote, false, "virtualizer write is blocked during user gesture");
eq(streamWrote, false, "stream write is blocked during user gesture");
eq(api!.canVirtualizerAdjust(), false, "virtualizer adjust freezes during user gesture");
});
eq(scrollTop, 400, "scrollTop stays put when compensating owners fire mid-gesture");
eq(writes.length, 0, "no compensating scroll writes are emitted mid-gesture");
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
// scrollend deterministically releases ownership and notifies idle subscribers.
let idleFires = 0;
const stopIdle = api!.onGestureIdle(() => {
idleFires += 1;
});
await act(async () => {
api!.markUserGesture();
});
eq(idleFires, 0, "gesture idle does not fire while the hold is active");
await act(async () => {
api!.gestureLastActivityRef.current -= 100;
api!.onScrollEnd();
});
eq(idleFires, 1, "scrollend releases the gesture exactly once");
eq(api!.canVirtualizerAdjust(), true, "virtualizer resumes immediately after scrollend");
stopIdle();
// A late scrollend from a controller write or an already-settled gesture must
// not manufacture another idle notification.
let strayIdleFires = 0;
const stopStrayIdle = api!.onGestureIdle(() => {
strayIdleFires += 1;
});
await act(async () => {
api!.onScrollEnd();
await new Promise((resolve) => setTimeout(resolve, 24));
});
eq(strayIdleFires, 0, "stray scrollend does not create a synthetic idle cycle");
stopStrayIdle();
// Windows middle-button auto-scroll has no wheel samples after activation.
scrollTop = 900;
await act(async () => {
api!.stick.current = true;
const captured = api!.onPointerDownIntent({ button: 1 } as React.PointerEvent<HTMLElement>);
eq(captured, true, "middle-button pointerdown starts a native scroll session");
});
eq(api!.stick.current, false, "middle-button auto-scroll releases tail-follow");
eq(api!.canVirtualizerAdjust(), false, "middle-button session freezes virtualizer compensation");
api!.gestureLastActivityRef.current -= 100;
api!.onScrollEnd();
// Unowned native scroll events cover scrollbar drags and continued auto-scroll.
scrollTop = 600;
api!.gestureUntilRef.current = 0;
await act(async () => {
api!.setMode("manual", "native-scroll-test");
api!.onScroll();
});
eq(api!.gestureSourceRef.current, "native-scroll", "unowned scroll starts a native gesture session");
eq(api!.canVirtualizerAdjust(), false, "native scroll freezes compensating writers");
api!.gestureLastActivityRef.current -= 100;
api!.onScrollEnd();
// Controller writes consume their own scroll event instead of masquerading as user input.
await act(async () => {
api!.setMode("manual", "programmatic-scroll-test");
const wrote = api!.writeOffset("virtualizer", 500);
eq(wrote, true, "virtualizer writes when no user gesture is active");
api!.onScroll();
});
eq(api!.gestureSourceRef.current, null, "owned scroll event does not create a user session");
eq(api!.canVirtualizerAdjust(), true, "owned scroll event keeps virtualizer writes enabled");
scrollTop = 900;
await act(async () => {
api!.stick.current = true;
@@ -0,0 +1,82 @@
// Run: tsx src/__tests__/transcript-measurement-invalidation.test.tsx
import { JSDOM } from "jsdom";
import React, { useRef } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useTranscriptMeasurementInvalidation } from "../lib/useTranscriptMeasurementInvalidation";
import { EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT } from "../lib/transcriptHeightCache";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\ntranscript measurement invalidation");
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", { pretendToBeVisual: true });
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
let allowMeasure = false;
let measureCalls = 0;
let reconcileCalls = 0;
let idleListener: (() => void) | null = null;
const virtualizer = { measure: () => { measureCalls += 1; } };
function Harness() {
const scrollRef = useRef<HTMLDivElement>(null);
const layoutSnapshotRef = useRef(EMPTY_TRANSCRIPT_LAYOUT_SNAPSHOT);
useTranscriptMeasurementInvalidation({
scrollRef,
layoutSnapshotRef,
virtualizer: virtualizer as never,
selectionActive: false,
canMeasure: () => allowMeasure,
onMeasureIdle: (listener) => {
idleListener = listener;
return () => { idleListener = null; };
},
captureViewportAnchor: () => ({ rowKey: "tab:row", viewportOffset: 12, generation: 1 }),
reconcileViewportAnchor: () => {
reconcileCalls += 1;
return true;
},
});
return <div ref={scrollRef} style={{ width: 600, fontSize: 14 }} />;
}
const root = createRoot(document.getElementById("root")!);
await act(async () => { root.render(<Harness />); });
eq(measureCalls, 0, "layout invalidation waits while a user gesture owns scrolling");
allowMeasure = true;
await act(async () => {
idleListener?.();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
eq(measureCalls, 1, "idle flush measures one pending layout invalidation");
eq(reconcileCalls, 1, "idle flush restores the captured viewport anchor");
await act(async () => {
idleListener?.();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
eq(measureCalls, 1, "ordinary gesture idle does not trigger an unconditional full measure");
await act(async () => { root.unmount(); });
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,41 @@
// Run: tsx src/__tests__/transcript-scroll-controller.test.ts
import {
canTranscriptScrollOwnerWrite,
shouldAdjustScrollOnItemSizeChange,
shouldRunStreamEndRepin,
} from "../lib/transcriptScrollController";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
ok(!shouldAdjustScrollOnItemSizeChange(true, "tail-follow"), "pinned tail-follow does not use anchor compensation");
ok(!shouldAdjustScrollOnItemSizeChange(true, "manual"), "a pinned viewport never uses anchor compensation");
ok(shouldAdjustScrollOnItemSizeChange(false, "tail-follow"), "an unpinned viewport may preserve its anchor");
ok(shouldAdjustScrollOnItemSizeChange(false, "manual"), "manual reading may preserve its anchor");
ok(!shouldAdjustScrollOnItemSizeChange(false, "native-selecting"), "native selection blocks anchor compensation");
ok(!shouldAdjustScrollOnItemSizeChange(false, "logical-selecting"), "logical selection blocks anchor compensation");
ok(shouldRunStreamEndRepin(true, false, true), "live-to-settled transition repins while pinned");
ok(!shouldRunStreamEndRepin(true, false, false), "stream end never yanks a detached reader");
ok(!shouldRunStreamEndRepin(false, true, true), "stream start does not schedule an end repin");
ok(!shouldRunStreamEndRepin(true, true, true), "an active stream does not run the end fallback");
ok(!shouldRunStreamEndRepin(false, false, true), "an idle transcript does not run the end fallback");
ok(canTranscriptScrollOwnerWrite("tail-follow", "row-size"), "tail-follow allows row-size repin");
ok(!canTranscriptScrollOwnerWrite("manual", "row-size"), "manual mode blocks row-size repin");
ok(!canTranscriptScrollOwnerWrite("programmatic", "row-size"), "programmatic mode blocks row-size repin");
ok(!canTranscriptScrollOwnerWrite("native-selecting", "row-size"), "selection mode blocks row-size repin");
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,99 @@
// Run: tsx src/__tests__/transcript-scroll-session.test.ts
import {
canTranscriptScrollOwnerWriteNow,
canVirtualizerAdjustScroll,
canScrollEndSettle,
GESTURE_HOLD_MS,
isUserGestureActive,
noteUserGesture,
} from "../lib/transcriptScrollSession";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
console.log("\ntranscript scroll session");
const t0 = 1_000_000;
const until = noteUserGesture(t0, GESTURE_HOLD_MS);
eq(until, t0 + GESTURE_HOLD_MS, "noteUserGesture extends hold from now");
ok(isUserGestureActive(until, t0 + 10), "gesture is active just after the sample");
ok(!isUserGestureActive(until, t0 + GESTURE_HOLD_MS + 1), "gesture expires after the hold window");
ok(!canScrollEndSettle(t0, t0 + 10), "stale scrollend cannot terminate fresh input");
ok(canScrollEndSettle(t0, t0 + 100), "scrollend settles after a quiet input window");
ok(
!canVirtualizerAdjustScroll("manual", until, t0 + 1),
"virtualizer adjust is frozen during user gesture",
);
ok(
canVirtualizerAdjustScroll("manual", until, t0 + GESTURE_HOLD_MS + 1),
"virtualizer adjust resumes after the gesture hold",
);
ok(
!canVirtualizerAdjustScroll("native-selecting", 0, t0),
"virtualizer adjust stays off during native selection",
);
ok(
!canTranscriptScrollOwnerWriteNow("manual", "virtualizer", until, t0 + 1),
"virtualizer cannot rewrite scrollTop mid-gesture",
);
ok(
!canTranscriptScrollOwnerWriteNow("tail-follow", "stream", until, t0 + 1),
"stream cannot yank to bottom mid-gesture",
);
ok(
!canTranscriptScrollOwnerWriteNow("tail-follow", "container-resize", until, t0 + 1),
"container-resize cannot rewrite scrollTop mid-gesture",
);
ok(
!canTranscriptScrollOwnerWriteNow("tail-follow", "row-size", until, t0 + 1),
"row-size cannot rewrite scrollTop mid-gesture",
);
ok(
canTranscriptScrollOwnerWriteNow("manual", "jump", until, t0 + 1),
"explicit jump still works during gesture",
);
ok(
canTranscriptScrollOwnerWriteNow("manual", "jump-bottom", until, t0 + 1),
"explicit jump-bottom still works during gesture",
);
ok(
canTranscriptScrollOwnerWriteNow("manual", "custom-scrollbar", until, t0 + 1),
"custom scrollbar drag still works during gesture",
);
ok(
canTranscriptScrollOwnerWriteNow("tail-follow", "stream", 0, t0),
"stream can write in tail-follow when no gesture is active",
);
ok(
canTranscriptScrollOwnerWriteNow("manual", "virtualizer", 0, t0),
"virtualizer can compensate after gesture ends",
);
ok(
!canTranscriptScrollOwnerWriteNow("native-selecting", "virtualizer", 0, t0),
"selection mode still blocks virtualizer writes",
);
ok(
canTranscriptScrollOwnerWriteNow("native-selecting", "selection-edge-scroll", 0, t0),
"selection edge scroll still works in selection mode",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -338,8 +338,13 @@ ok(
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta__control--profile\s*\{[\s\S]*?max-width\s*:\s*126px[\s\S]*?\.composer-meta__control--intent\s*\{[\s\S]*?max-width\s*:\s*128px[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
"composer compact controls activate at the capped theme width",
);
eq(finalDeclaration(".md table", "overflow-x"), "auto", "markdown tables scroll horizontally");
eq(finalDeclaration(".code", "overflow"), "auto", "code blocks scroll instead of widening the layout");
eq(finalDeclaration(".md-table-scroll", "overflow-x"), "auto", "markdown table wrapper scrolls horizontally");
eq(finalDeclaration(".md-table-scroll", "overflow-y"), "hidden", "markdown table wrapper does not nest vertical scroll");
eq(finalDeclaration(".md table", "overflow"), "visible", "markdown tables stay in document flow for trackpad Y");
eq(finalDeclaration(".md-table-fold", "display"), "flex", "large tables use a fold stack for preview + expand");
eq(finalDeclaration(".md-table-fold__toggle", "cursor"), "pointer", "table expand control is clickable");
eq(finalDeclaration(".code", "overflow-x"), "auto", "code blocks scroll horizontally instead of widening the layout");
eq(finalDeclaration(".code", "overflow-y"), "hidden", "code blocks do not nest vertical scroll by default");
ok(
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.settings-center\s*\{[\s\S]*?grid-template-columns\s*:\s*1fr/.test(styles),
"settings center stacks navigation before the modal is too narrow",
+192 -47
View File
@@ -1,15 +1,29 @@
// MarkdownTable — components-map override for markdown tables. Small tables
// render as-is; tables with more than MARKDOWN_TABLE_VIRTUAL_MIN_ROWS body
// rows mount through @tanstack/react-virtual inside a bounded-height scroll
// container (same idiom as the transcript list), so a thousand-row tool dump
// cannot monopolize a frame. Content fidelity is kept: every row is in the
// virtual model, just not mounted until scrolled to.
// MarkdownTable — components-map override for markdown tables.
//
// Small tables stay in document flow (horizontal overflow only).
// Large tables default to a short in-flow preview + "Expand all", so the
// trackpad never latches onto a nested vertical scroller while reading.
// Expanding mounts a bounded virtual scroller (with nested-scroll handoff)
// for thousand-row dumps without monopolizing a frame.
import { Children, cloneElement, isValidElement, memo, useRef, type CSSProperties, type ReactElement, type ReactNode } from "react";
import {
Children,
cloneElement,
isValidElement,
memo,
useState,
useRef,
type CSSProperties,
type ReactElement,
type ReactNode,
} from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { MarkdownTableAlignment, VirtualMarkdownTableData } from "../lib/largeMarkdownTable";
import { t } from "../lib/i18n";
export const MARKDOWN_TABLE_VIRTUAL_MIN_ROWS = 50;
/** How many body rows to show in the default collapsed preview. */
export const MARKDOWN_TABLE_PREVIEW_ROWS = 12;
const VIRTUAL_TABLE_MAX_HEIGHT = 480;
const ESTIMATED_ROW_HEIGHT = 36;
const VIRTUAL_TABLE_OVERSCAN = 12;
@@ -29,6 +43,26 @@ function tableRows(tbody: ReactElement | null): ReactElement[] {
);
}
function TableFoldToggle({
expanded,
totalRows,
onToggle,
}: {
expanded: boolean;
totalRows: number;
onToggle: () => void;
}) {
// Module-level t() so unit tests do not need a LocaleProvider; App still
// re-renders under LocaleProvider when the locale flips.
return (
<button type="button" className="md-table-fold__toggle" onClick={onToggle}>
{expanded
? t("markdown.tableCollapse")
: t("markdown.tableExpandAll", { n: totalRows })}
</button>
);
}
const VirtualMarkdownTable = memo(function VirtualMarkdownTable({
head,
rows,
@@ -47,7 +81,12 @@ const VirtualMarkdownTable = memo(function VirtualMarkdownTable({
initialRect: { width: 640, height: VIRTUAL_TABLE_MAX_HEIGHT },
});
return (
<div ref={scrollRef} className="md-table-virtual" style={{ maxHeight: VIRTUAL_TABLE_MAX_HEIGHT }}>
<div
ref={scrollRef}
className="md-table-virtual"
data-nested-scroll=""
style={{ maxHeight: VIRTUAL_TABLE_MAX_HEIGHT }}
>
<table>
{head}
<tbody style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
@@ -73,11 +112,54 @@ const VirtualMarkdownTable = memo(function VirtualMarkdownTable({
);
});
const CollapsibleLargeMarkdownTable = memo(function CollapsibleLargeMarkdownTable({
head,
rows,
}: {
head: ReactElement | null;
rows: ReactElement[];
}) {
const [expanded, setExpanded] = useState(false);
const preview = rows.slice(0, MARKDOWN_TABLE_PREVIEW_ROWS);
return (
<div className="md-table-fold" data-expanded={expanded ? "true" : undefined}>
{expanded ? (
<VirtualMarkdownTable head={head} rows={rows} />
) : (
<div className="md-table-scroll">
<table>
{head}
<tbody>
{preview.map((row, index) =>
cloneElement(row, { key: row.key ?? `preview-${index}` } as Partial<unknown>),
)}
</tbody>
</table>
</div>
)}
<TableFoldToggle
expanded={expanded}
totalRows={rows.length}
onToggle={() => setExpanded((v) => !v)}
/>
</div>
);
});
export const MarkdownTable = memo(function MarkdownTable({ children }: { children?: ReactNode }) {
const tbody = findTablePart(children, "tbody");
const rows = tableRows(tbody);
if (rows.length <= MARKDOWN_TABLE_VIRTUAL_MIN_ROWS) return <table>{children}</table>;
return <VirtualMarkdownTable head={findTablePart(children, "thead")} rows={rows} />;
// Small tables stay in document flow. Horizontal overflow lives on a wrapper
// (overflow-y:hidden) so CSS does not promote overflow-y to auto and steal
// trackpad Y from the transcript.
if (rows.length <= MARKDOWN_TABLE_VIRTUAL_MIN_ROWS) {
return (
<div className="md-table-scroll">
<table>{children}</table>
</div>
);
}
return <CollapsibleLargeMarkdownTable head={findTablePart(children, "thead")} rows={rows} />;
});
function alignmentProps(align: MarkdownTableAlignment): { align?: "left" | "center" | "right" } {
@@ -90,54 +172,117 @@ export const VirtualMarkdownSourceTable = memo(function VirtualMarkdownSourceTab
}: {
data: VirtualMarkdownTableData;
}) {
const [expanded, setExpanded] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const totalRows = data.rows.length;
const isLarge = totalRows > MARKDOWN_TABLE_VIRTUAL_MIN_ROWS;
const visibleRows = expanded || !isLarge
? data.rows
: data.rows.slice(0, MARKDOWN_TABLE_PREVIEW_ROWS);
const virtualizer = useVirtualizer({
count: data.rows.length,
count: expanded && isLarge ? totalRows : 0,
getScrollElement: () => scrollRef.current,
estimateSize: () => ESTIMATED_ROW_HEIGHT,
overscan: VIRTUAL_TABLE_OVERSCAN,
initialRect: { width: 640, height: VIRTUAL_TABLE_MAX_HEIGHT },
});
return (
<div
ref={scrollRef}
className="md-table-virtual"
data-markdown-source-rows={data.rows.length}
style={{ maxHeight: VIRTUAL_TABLE_MAX_HEIGHT }}
>
<table>
<thead>
<tr>
{data.header.map((cell, index) => (
<th key={index} {...alignmentProps(data.align[index] ?? null)}>{cell}</th>
))}
</tr>
</thead>
<tbody style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = data.rows[virtualRow.index];
if (!row) return null;
return (
<tr
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
if (!isLarge) {
return (
<div className="md-table-scroll" data-markdown-source-rows={totalRows}>
<table>
<thead>
<tr>
{data.header.map((cell, index) => (
<th key={index} {...alignmentProps(data.align[index] ?? null)}>{cell}</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, index) => (
<td key={index} {...alignmentProps(data.align[index] ?? null)}>{cell}</td>
))}
</tr>
);
})}
</tbody>
</table>
))}
</tbody>
</table>
</div>
);
}
return (
<div className="md-table-fold" data-expanded={expanded ? "true" : undefined} data-markdown-source-rows={totalRows}>
{expanded ? (
<div
ref={scrollRef}
className="md-table-virtual"
data-nested-scroll=""
style={{ maxHeight: VIRTUAL_TABLE_MAX_HEIGHT }}
>
<table>
<thead>
<tr>
{data.header.map((cell, index) => (
<th key={index} {...alignmentProps(data.align[index] ?? null)}>{cell}</th>
))}
</tr>
</thead>
<tbody style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = data.rows[virtualRow.index];
if (!row) return null;
return (
<tr
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{row.map((cell, index) => (
<td key={index} {...alignmentProps(data.align[index] ?? null)}>{cell}</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="md-table-scroll">
<table>
<thead>
<tr>
{data.header.map((cell, index) => (
<th key={index} {...alignmentProps(data.align[index] ?? null)}>{cell}</th>
))}
</tr>
</thead>
<tbody>
{visibleRows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, index) => (
<td key={index} {...alignmentProps(data.align[index] ?? null)}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<TableFoldToggle
expanded={expanded}
totalRows={totalRows}
onToggle={() => setExpanded((v) => !v)}
/>
</div>
);
});
+72 -191
View File
@@ -1,5 +1,5 @@
import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
import type { ControllerLiveStore, Item, LiveStream } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types";
import type { InvocationMetadataMap } from "../lib/invocationDisplay";
@@ -16,6 +16,7 @@ import { getProcessFoldPreference, onProcessFoldPreferenceChange, type ProcessFo
import { STEER_NOTICE_PREFIX, isSteerNoticeText } from "../lib/useController";
import { useTranscriptEntranceAnimation } from "../lib/useEntranceAnimation";
import { useTranscriptScrollController } from "../lib/useTranscriptScrollController";
import { shouldAdjustScrollOnItemSizeChange, shouldRunStreamEndRepin } from "../lib/transcriptScrollController";
import { useTranscriptSelectionRetention } from "../lib/useTranscriptSelectionRetention";
import { useTranscriptMeasurementInvalidation } from "../lib/useTranscriptMeasurementInvalidation";
import { useTranscriptRowMeasurements } from "../lib/useTranscriptRowMeasurements";
@@ -39,7 +40,6 @@ import {
type TranscriptLiveFlags,
type TranscriptRow,
} from "../lib/transcriptRows";
import { observeScrollContentSize } from "../lib/scrollContentObserver";
import { getTranscriptStore } from "../lib/transcriptStore";
import { acquireMarkdownWorkerClient, releaseMarkdownWorkerClient } from "../lib/markdownWorkerClient";
import { noteTranscriptRowCounts } from "../lib/sessionDiagnostics";
@@ -48,6 +48,8 @@ import { InlineAssistantReasoning } from "./InlineAssistantReasoning";
import { LiveStreamContext } from "./LiveStreamContext";
import { useTranscriptSelectableRows } from "../lib/useTranscriptSelectableRows";
import { TranscriptSelectionOverlay } from "./TranscriptSelectionOverlay";
import { useCreationTranscriptScrollbar } from "../lib/useCreationTranscriptScrollbar";
import { useTranscriptScrollInteractions } from "../lib/useTranscriptScrollInteractions";
type OpenTurnAction = { turn: number; menu: "summary" | "rewind" };
const QUESTION_NAV_MIN_COUNT = 2;
type AssistantReasoningDisplay = "normal" | "hide";
@@ -205,7 +207,10 @@ export function Transcript({
scrollRef,
stick,
onScroll,
onScrollEnd,
onWheelIntent,
onPointerDownIntent,
onNestedScrollIntent,
onTouchStartIntent,
onTouchMoveIntent,
onKeyScrollIntent,
@@ -217,185 +222,17 @@ export function Transcript({
lastClientHeight,
lastFooterHeight,
setMode: setScrollMode,
modeRef: scrollModeRef,
writeOffset,
resetGeneration,
canVirtualizerAdjust,
captureViewportAnchor,
reconcileViewportAnchor,
onGestureIdle,
finishProgrammaticScroll,
} = useTranscriptScrollController();
const autoScrollFrame = useRef<number | null>(null);
const pendingRevealBottomScroll = useRef(false);
// Creation uses a custom scrollbar (native WebView2 thumb size is unreliable).
// Thin by default; only thickens when pointer is near the right rail / dragging.
const [creationScrollbar, setCreationScrollbar] = useState({
visible: false,
hot: false,
thumbTop: 0,
thumbHeight: 0,
});
const creationScrollbarHotRef = useRef(false);
const creationScrollbarDragRef = useRef<{ pointerId: number; startY: number; startScrollTop: number } | null>(null);
const SCROLLBAR_HOT_ZONE_PX = 18;
const SCROLLBAR_MIN_THUMB_PX = 28;
const syncCreationScrollbarMetrics = useCallback(() => {
if (!creationMode) return;
const el = scrollRef.current;
if (!el) {
setCreationScrollbar((prev) => (prev.visible || prev.hot ? { visible: false, hot: false, thumbTop: 0, thumbHeight: 0 } : prev));
return;
}
const { scrollTop, scrollHeight, clientHeight } = el;
const overflow = scrollHeight - clientHeight;
if (overflow <= 1 || clientHeight <= 0) {
setCreationScrollbar((prev) => (prev.visible || prev.hot ? { visible: false, hot: false, thumbTop: 0, thumbHeight: 0 } : prev));
return;
}
const thumbHeight = Math.max(SCROLLBAR_MIN_THUMB_PX, Math.round((clientHeight / scrollHeight) * clientHeight));
const maxThumbTop = Math.max(0, clientHeight - thumbHeight);
const thumbTop = Math.round((scrollTop / overflow) * maxThumbTop);
setCreationScrollbar((prev) => {
if (
prev.visible &&
prev.thumbTop === thumbTop &&
prev.thumbHeight === thumbHeight &&
prev.hot === creationScrollbarHotRef.current
) {
return prev;
}
return {
visible: true,
hot: creationScrollbarHotRef.current,
thumbTop,
thumbHeight,
};
});
}, [SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef]);
const setCreationScrollbarHot = useCallback((next: boolean) => {
if (creationScrollbarHotRef.current === next) return;
creationScrollbarHotRef.current = next;
setCreationScrollbar((prev) => (prev.hot === next ? prev : { ...prev, hot: next }));
}, []);
useEffect(() => {
if (!creationMode) {
creationScrollbarHotRef.current = false;
creationScrollbarDragRef.current = null;
setCreationScrollbar({ visible: false, hot: false, thumbTop: 0, thumbHeight: 0 });
return;
}
const onPointerMove = (event: PointerEvent) => {
const drag = creationScrollbarDragRef.current;
const el = scrollRef.current;
if (drag && el) {
const overflow = el.scrollHeight - el.clientHeight;
if (overflow > 0) {
const thumbHeight = Math.max(SCROLLBAR_MIN_THUMB_PX, Math.round((el.clientHeight / el.scrollHeight) * el.clientHeight));
const maxThumbTop = Math.max(0, el.clientHeight - thumbHeight);
const startThumbTop = (drag.startScrollTop / overflow) * maxThumbTop;
const nextThumbTop = Math.min(maxThumbTop, Math.max(0, startThumbTop + (event.clientY - drag.startY)));
setScrollMode("programmatic", "custom-scrollbar-drag");
writeOffset("custom-scrollbar", maxThumbTop > 0 ? (nextThumbTop / maxThumbTop) * overflow : 0);
syncCreationScrollbarMetrics();
}
setCreationScrollbarHot(true);
return;
}
if (!el || el.scrollHeight <= el.clientHeight + 1) {
setCreationScrollbarHot(false);
return;
}
const rect = el.getBoundingClientRect();
const inY = event.clientY >= rect.top && event.clientY <= rect.bottom;
const fromRight = rect.right - event.clientX;
setCreationScrollbarHot(inY && fromRight >= -2 && fromRight <= SCROLLBAR_HOT_ZONE_PX);
};
const endDrag = (event?: PointerEvent) => {
if (!creationScrollbarDragRef.current) return;
creationScrollbarDragRef.current = null;
const el = scrollRef.current;
if (!el || !event) {
setCreationScrollbarHot(false);
return;
}
const rect = el.getBoundingClientRect();
const inY = event.clientY >= rect.top && event.clientY <= rect.bottom;
const fromRight = rect.right - event.clientX;
setCreationScrollbarHot(inY && fromRight >= -2 && fromRight <= SCROLLBAR_HOT_ZONE_PX);
};
const onPointerUp = (event: PointerEvent) => endDrag(event);
const onBlur = () => endDrag();
syncCreationScrollbarMetrics();
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerup", onPointerUp, { passive: true });
window.addEventListener("pointercancel", onPointerUp, { passive: true });
window.addEventListener("blur", onBlur);
window.addEventListener("resize", syncCreationScrollbarMetrics);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
window.removeEventListener("blur", onBlur);
window.removeEventListener("resize", syncCreationScrollbarMetrics);
creationScrollbarHotRef.current = false;
creationScrollbarDragRef.current = null;
setCreationScrollbar({ visible: false, hot: false, thumbTop: 0, thumbHeight: 0 });
};
}, [SCROLLBAR_HOT_ZONE_PX, SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef, setCreationScrollbarHot, setScrollMode, syncCreationScrollbarMetrics, writeOffset]);
const handleCreationScroll = useCallback(() => {
onScroll();
if (creationMode) syncCreationScrollbarMetrics();
}, [creationMode, onScroll, syncCreationScrollbarMetrics]);
useLayoutEffect(() => {
if (!creationMode) return;
syncCreationScrollbarMetrics();
}, [creationMode, items.length, syncCreationScrollbarMetrics]);
useEffect(() => {
if (!creationMode || !scrollRef.current) return;
return observeScrollContentSize(scrollRef.current, syncCreationScrollbarMetrics);
}, [creationMode, scrollRef, syncCreationScrollbarMetrics]);
const handleCreationScrollbarThumbPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!creationMode) return;
const el = scrollRef.current;
if (!el) return;
event.preventDefault();
event.stopPropagation();
creationScrollbarDragRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
startScrollTop: el.scrollTop,
};
event.currentTarget.setPointerCapture(event.pointerId);
setCreationScrollbarHot(true);
}, [creationMode, scrollRef, setCreationScrollbarHot]);
const handleCreationScrollbarRailPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!creationMode) return;
if ((event.target as HTMLElement | null)?.closest?.(".transcript__scrollbar-thumb")) return;
const el = scrollRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const overflow = el.scrollHeight - el.clientHeight;
if (overflow <= 1) return;
const thumbHeight = Math.max(SCROLLBAR_MIN_THUMB_PX, Math.round((el.clientHeight / el.scrollHeight) * el.clientHeight));
const maxThumbTop = Math.max(0, el.clientHeight - thumbHeight);
const y = event.clientY - rect.top - thumbHeight / 2;
const nextThumbTop = Math.min(maxThumbTop, Math.max(0, y));
setScrollMode("programmatic", "custom-scrollbar-rail");
writeOffset("custom-scrollbar", maxThumbTop > 0 ? (nextThumbTop / maxThumbTop) * overflow : 0);
syncCreationScrollbarMetrics();
setCreationScrollbarHot(true);
}, [SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef, setCreationScrollbarHot, setScrollMode, syncCreationScrollbarMetrics, writeOffset]);
const entranceRef = useTranscriptEntranceAnimation<HTMLDivElement>(tabId, revealSignal, items);
@@ -413,17 +250,20 @@ export function Transcript({
}
}, []);
const handleWheelIntent = useCallback((event: React.WheelEvent<HTMLElement>) => {
if (onWheelIntent(event)) cancelStreamingAutoScroll();
}, [cancelStreamingAutoScroll, onWheelIntent]);
const handleTouchMoveIntent = useCallback((event: React.TouchEvent<HTMLElement>) => {
if (onTouchMoveIntent(event)) cancelStreamingAutoScroll();
}, [cancelStreamingAutoScroll, onTouchMoveIntent]);
const handleKeyScrollIntent = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (onKeyScrollIntent(event)) cancelStreamingAutoScroll();
}, [cancelStreamingAutoScroll, onKeyScrollIntent]);
const {
state: creationScrollbar,
handleScroll: handleCreationScroll,
onThumbPointerDown: handleCreationScrollbarThumbPointerDown,
onRailPointerDown: handleCreationScrollbarRailPointerDown,
} = useCreationTranscriptScrollbar({
enabled: creationMode,
contentRevision: items.length,
scrollRef,
onScroll,
setScrollMode,
writeOffset,
finishProgrammaticScroll,
});
const questions = useMemo<QuestionAnchor[]>(() => {
const anchors: QuestionAnchor[] = [];
@@ -494,6 +334,21 @@ export function Transcript({
};
}, []);
// The settled assistant row can grow after turn_done while markdown swaps
// from the live renderer to parsed blocks. Run a passive fallback while the
// reader is still pinned; row measurements below cover later async growth.
const previousLiveRef = useRef<{ tabId: string | undefined; id: string | undefined }>({ tabId, id: undefined });
useEffect(() => {
const previous = previousLiveRef.current;
const sameTab = previous.tabId === tabId;
const hadLive = sameTab && previous.id !== undefined;
const hasLive = live?.id !== undefined;
previousLiveRef.current = { tabId, id: live?.id };
if (shouldRunStreamEndRepin(hadLive, hasLive, stick.current)) {
scrollToBottomAfterLayout(3, "stream");
}
}, [live?.id, scrollToBottomAfterLayout, stick, tabId]);
// ResizeObserver for container height changes.
useEffect(() => {
const el = scrollRef.current;
@@ -621,14 +476,33 @@ export function Transcript({
captureViewportAnchor,
reconcileViewportAnchor,
});
const scrollInteractions = useTranscriptScrollInteractions({
scrollRef,
cancelStreamingScroll: cancelStreamingAutoScroll,
onWheelIntent,
onTouchMoveIntent,
onKeyScrollIntent,
onPointerDownIntent,
onNestedScrollIntent,
onScrollEnd,
onSelectionPointerDown: selectionRetention.onPointerDownCapture,
});
const getRowKey = useCallback((index: number) => `${tabId ?? ""}:${String(rows[index]?.key ?? index)}`, [rows, tabId]);
const { estimateSize: estimateRowSize, layoutSnapshotRef, measureElement: measureRowSize } = useTranscriptRowMeasurements(tabId, rows);
const trackRowSizeChange = useCallback(
(element: HTMLDivElement, entry: ResizeObserverEntry | undefined, instance: Virtualizer<HTMLDivElement, HTMLDivElement>) => {
const height = measureRowSize(element, entry, instance);
if (stick.current) scheduleRepinIfWasPinned(0, "row-size");
return height;
},
[measureRowSize, scheduleRepinIfWasPinned, stick],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
getItemKey: getRowKey,
estimateSize: estimateRowSize,
measureElement: measureRowSize,
measureElement: trackRowSizeChange,
overscan: VIRTUAL_OVERSCAN_ROWS,
rangeExtractor: selectionRetention.rangeExtractor,
// Key-anchored compensation: prepended history pages, fold toggles and
@@ -645,12 +519,19 @@ export function Transcript({
useAnimationFrameWithResizeObserver: true,
onChange: () => selectionRetention.reconcileLogicalFocus(),
});
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => canVirtualizerAdjust();
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => (
canVirtualizerAdjust()
&& shouldAdjustScrollOnItemSizeChange(stick.current, scrollModeRef.current)
);
useTranscriptMeasurementInvalidation({
scrollRef,
layoutSnapshotRef,
virtualizer,
selectionActive: selectionRetention.active,
canMeasure: canVirtualizerAdjust,
onMeasureIdle: onGestureIdle,
captureViewportAnchor,
reconcileViewportAnchor,
});
const sizerRef = useCallback(
@@ -840,11 +721,11 @@ export function Transcript({
className={`transcript${empty ? " transcript--empty" : ""}${creationMode ? " transcript--creation-scrollbar" : ""}${creationMode && creationScrollbar.hot ? " transcript--scrollbar-hot" : ""}`}
ref={scrollRef}
onScroll={creationMode ? handleCreationScroll : onScroll}
onWheelCapture={handleWheelIntent}
onWheelCapture={scrollInteractions.onWheelCapture}
onTouchStartCapture={onTouchStartIntent}
onTouchMoveCapture={handleTouchMoveIntent}
onKeyDownCapture={handleKeyScrollIntent}
onPointerDownCapture={selectionRetention.onPointerDownCapture}
onTouchMoveCapture={scrollInteractions.onTouchMoveCapture}
onKeyDownCapture={scrollInteractions.onKeyDownCapture}
onPointerDownCapture={scrollInteractions.onPointerDownCapture}
>
{empty && !hydrating && <Welcome onPrompt={onPrompt} variant={welcomeVariant} />}
@@ -0,0 +1,171 @@
/**
* Nested scroll handoff for transcript reading.
*
* Trackpads latch wheel events to the first overflow≠visible ancestor under the
* pointer — including elements that only set overflow-x:auto (CSS then computes
* overflow-y to auto). When that ancestor cannot scroll further in the gesture
* direction, continued wheels stall instead of moving the outer transcript.
*
* This helper promotes those edge / non-scrollable vertical wheels to the
* parent scroller and latches to the parent for the rest of the gesture.
*/
export const NESTED_SCROLL_ATTR = "data-nested-scroll";
const EDGE_EPSILON_PX = 1;
const DEFAULT_LINE_HEIGHT_PX = 16;
export type NestedScrollHandoffOptions = {
/** Outer reading scroller (`.transcript`). */
parent: HTMLElement;
/** Called with normalized deltaY when a nested edge wheel is promoted. */
onParentScrollIntent?: (deltaY: number) => void;
/** Latch to the parent after the first edge handoff until this many ms of silence. */
latchHoldMs?: number;
now?: () => number;
};
export type NestedScrollHandoff = {
detach: () => void;
};
/** Normalize WheelEvent line/page deltas before applying them to scrollTop. */
export function normalizeWheelDelta(event: Pick<WheelEvent, "deltaX" | "deltaY" | "deltaMode">, viewport: HTMLElement) {
if (event.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
return { x: event.deltaX, y: event.deltaY };
}
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
const page = Math.max(1, viewport.clientHeight);
return { x: event.deltaX * page, y: event.deltaY * page };
}
const computed = typeof getComputedStyle === "function" ? getComputedStyle(viewport).lineHeight : "";
const parsed = Number.parseFloat(computed);
const line = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LINE_HEIGHT_PX;
return { x: event.deltaX * line, y: event.deltaY * line };
}
function overflowAllowsScroll(value: string): boolean {
return value === "auto" || value === "scroll" || value === "overlay";
}
/** True when the element is a CSS scroll container that can still move in deltaY. */
export function canElementScrollVertically(el: HTMLElement, deltaY: number): boolean {
const style = typeof getComputedStyle === "function" ? getComputedStyle(el) : null;
if (style && !overflowAllowsScroll(style.overflowY) && !overflowAllowsScroll(style.overflowX)) {
// overflow-x:auto forces overflow-y to compute as auto in CSS; if both are
// visible, this is not a scroll container.
return false;
}
const overflow = el.scrollHeight - el.clientHeight;
if (overflow <= EDGE_EPSILON_PX) return false;
if (deltaY < 0) return el.scrollTop > EDGE_EPSILON_PX;
if (deltaY > 0) return el.scrollTop + el.clientHeight < el.scrollHeight - EDGE_EPSILON_PX;
return false;
}
/**
* Walk from the event target up to (but not including) parent and return the
* nearest element that can absorb this vertical delta. Prefers explicitly
* marked `[data-nested-scroll]` nodes when they are on the path.
*/
export function findVerticalScrollTarget(
target: EventTarget | null,
parent: HTMLElement,
deltaY: number,
): HTMLElement | null {
if (!(target instanceof Element)) return null;
let node: Element | null = target;
while (node && node !== parent) {
if (node instanceof HTMLElement && node !== parent) {
if (canElementScrollVertically(node, deltaY)) return node;
}
node = node.parentElement;
}
return null;
}
/**
* True when the event target sits under a nested overflow container that would
* steal vertical trackpad gestures even though it cannot scroll further in Y.
* Those are the "sticky table / sticky code" cases from the mac recording.
*/
export function shouldHandoffVerticalWheel(
target: EventTarget | null,
parent: HTMLElement,
deltaY: number,
): boolean {
if (!(target instanceof Element) || !parent.contains(target) || target === parent) return false;
// Explicit markers always participate in handoff at their edge.
const marked = target.closest(`[${NESTED_SCROLL_ATTR}]`);
if (marked instanceof HTMLElement && parent.contains(marked) && marked !== parent) {
return !canElementScrollVertically(marked, deltaY);
}
// Any intermediate overflow container that cannot scroll in this direction
// will latch the trackpad; promote those wheels to the parent.
let node: Element | null = target;
while (node && node !== parent) {
if (node instanceof HTMLElement) {
const style = typeof getComputedStyle === "function" ? getComputedStyle(node) : null;
if (style && (overflowAllowsScroll(style.overflowY) || overflowAllowsScroll(style.overflowX))) {
// Found a nested scrollport. If it can still scroll in Y, leave it alone;
// otherwise the gesture would stall on this node.
return !canElementScrollVertically(node, deltaY);
}
}
node = node.parentElement;
}
return false;
}
/**
* Attach a capture-phase wheel listener on `parent` that handoffs edge wheels
* from nested overflow containers to the parent. Returns a detach function.
*/
export function attachNestedScrollHandoff(options: NestedScrollHandoffOptions): NestedScrollHandoff {
const {
parent,
onParentScrollIntent,
latchHoldMs = 220,
now = () => Date.now(),
} = options;
let latchUntil = 0;
const onWheel = (event: WheelEvent) => {
// Pinch-zoom is synthesized as ctrl+wheel on macOS trackpads.
if (event.ctrlKey || event.defaultPrevented) return;
const delta = normalizeWheelDelta(event, parent);
if (delta.y === 0) return;
if (Math.abs(delta.x) > Math.abs(delta.y)) return;
const t = now();
const latched = latchUntil > t;
// After the first edge handoff, keep driving the parent for the rest of
// this trackpad gesture so the user does not re-latch into the nested box.
if (latched) {
event.preventDefault();
onParentScrollIntent?.(delta.y);
parent.scrollTop += delta.y;
latchUntil = t + latchHoldMs;
return;
}
// Nested scroller can still absorb this delta — let the browser handle it.
if (findVerticalScrollTarget(event.target, parent, delta.y)) return;
if (!shouldHandoffVerticalWheel(event.target, parent, delta.y)) return;
event.preventDefault();
onParentScrollIntent?.(delta.y);
parent.scrollTop += delta.y;
latchUntil = t + latchHoldMs;
};
parent.addEventListener("wheel", onWheel, { capture: true, passive: false });
return {
detach: () => parent.removeEventListener("wheel", onWheel, { capture: true } as AddEventListenerOptions),
};
}
@@ -10,6 +10,7 @@ export type TranscriptScrollOwner =
| "stream"
| "container-resize"
| "footer-resize"
| "row-size"
| "jump"
| "rewind"
| "jump-bottom"
@@ -37,14 +38,37 @@ export function isTranscriptSelectionMode(mode: TranscriptScrollMode): boolean {
/**
* Central scroll-write arbitration. Browser-originated scrolling does not use
* this path; every programmatic scrollTop write must name its owner here.
*
* Note: user-gesture locking is layered on top in
* `canTranscriptScrollOwnerWriteNow` (transcriptScrollSession.ts). Prefer that
* helper from runtime scroll writers so trackpad gestures are not fought by
* virtualizer/stream compensation.
*/
export function canTranscriptScrollOwnerWrite(mode: TranscriptScrollMode, owner: TranscriptScrollOwner): boolean {
if (isTranscriptSelectionMode(mode)) return owner === "selection-edge-scroll";
if (owner === "selection-edge-scroll") return false;
if (owner === "stream" || owner === "container-resize" || owner === "footer-resize") {
if (owner === "stream" || owner === "container-resize" || owner === "footer-resize" || owner === "row-size") {
return mode === "tail-follow";
}
// Virtualizer compensation is allowed outside selection; the session layer
// (`canTranscriptScrollOwnerWriteNow`) additionally blocks it mid-gesture.
if (owner === "virtualizer") return true;
if (EXPLICIT_OWNERS.has(owner)) return true;
return mode === "reconciling";
}
/**
* A pinned viewport follows row growth through a bottom repin. Applying anchor
* compensation at the same time lifts it away from the tail and makes the two
* writers fight. Detached readers still need anchor compensation to preserve
* their reading position; selection owns the viewport separately.
*/
export function shouldAdjustScrollOnItemSizeChange(pinned: boolean, mode: TranscriptScrollMode): boolean {
if (pinned) return false;
return !isTranscriptSelectionMode(mode);
}
/** Run the layout fallback only for a live-to-settled transition that is still pinned. */
export function shouldRunStreamEndRepin(hadLive: boolean, hasLive: boolean, pinned: boolean): boolean {
return hadLive && !hasLive && pinned;
}
@@ -0,0 +1,78 @@
import {
canTranscriptScrollOwnerWrite,
isTranscriptSelectionMode,
type TranscriptScrollMode,
type TranscriptScrollOwner,
} from "./transcriptScrollController";
/**
* How long after the last vertical wheel/touch sample we treat the user as
* still gesturing. During this window, virtualizer/stream/resize must not
* rewrite scrollTop — that fight is the main source of trackpad stall/jump.
*/
export const GESTURE_HOLD_MS = 220;
export const SCROLLEND_QUIET_MS = 48;
export type TranscriptUserScrollSource =
| "wheel"
| "touch"
| "keyboard"
| "middle-button"
| "native-scroll"
| "nested-scroll";
/** Owners that compete with native trackpad inertia and must stay silent mid-gesture. */
const GESTURE_BLOCKED_OWNERS = new Set<TranscriptScrollOwner>([
"virtualizer",
"stream",
"container-resize",
"footer-resize",
"row-size",
]);
export function noteUserGesture(now = Date.now(), holdMs = GESTURE_HOLD_MS): number {
return now + holdMs;
}
export function isUserGestureActive(gestureUntil: number, now = Date.now()): boolean {
return gestureUntil > now;
}
/** Reject a late scrollend from an older gesture if fresh input just arrived. */
export function canScrollEndSettle(
lastActivityAt: number,
now = Date.now(),
quietMs = SCROLLEND_QUIET_MS,
): boolean {
return lastActivityAt > 0 && now - lastActivityAt >= quietMs;
}
/**
* Scroll-write arbitration with a short-lived user-gesture lock layered on top
* of the existing mode/owner matrix.
*
* Explicit user actions (jump, jump-bottom, custom scrollbar, selection edge)
* still pass when the base matrix allows them. Compensating writers do not.
*/
export function canTranscriptScrollOwnerWriteNow(
mode: TranscriptScrollMode,
owner: TranscriptScrollOwner,
gestureUntil: number,
now = Date.now(),
): boolean {
if (isUserGestureActive(gestureUntil, now) && GESTURE_BLOCKED_OWNERS.has(owner)) {
return false;
}
return canTranscriptScrollOwnerWrite(mode, owner);
}
/** Virtualizer height compensation is forbidden while the user is scrolling or selecting. */
export function canVirtualizerAdjustScroll(
mode: TranscriptScrollMode,
gestureUntil: number,
now = Date.now(),
): boolean {
if (isTranscriptSelectionMode(mode)) return false;
if (isUserGestureActive(gestureUntil, now)) return false;
return true;
}
@@ -0,0 +1,216 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject,
} from "react";
import { observeScrollContentSize } from "./scrollContentObserver";
import type { TranscriptScrollMode, TranscriptScrollOwner } from "./transcriptScrollController";
const HOT_ZONE_PX = 18;
const MIN_THUMB_PX = 28;
type ScrollbarState = {
visible: boolean;
hot: boolean;
thumbTop: number;
thumbHeight: number;
};
type DragGeometry = {
pointerId: number;
startY: number;
startThumbTop: number;
overflow: number;
maxThumbTop: number;
thumbHeight: number;
};
const HIDDEN_STATE: ScrollbarState = { visible: false, hot: false, thumbTop: 0, thumbHeight: 0 };
export function readCreationScrollbarGeometry(clientHeight: number, scrollHeight: number) {
const overflow = scrollHeight - clientHeight;
if (overflow <= 1 || clientHeight <= 0) return null;
const thumbHeight = Math.max(MIN_THUMB_PX, Math.round((clientHeight / scrollHeight) * clientHeight));
const maxThumbTop = Math.max(0, clientHeight - thumbHeight);
return { overflow, thumbHeight, maxThumbTop };
}
function readGeometry(element: HTMLElement) {
return readCreationScrollbarGeometry(element.clientHeight, element.scrollHeight);
}
export function mapFrozenScrollbarDrag(
drag: Pick<DragGeometry, "startY" | "startThumbTop" | "overflow" | "maxThumbTop">,
clientY: number,
) {
const thumbTop = Math.min(drag.maxThumbTop, Math.max(0, drag.startThumbTop + clientY - drag.startY));
const scrollTop = drag.maxThumbTop > 0 ? (thumbTop / drag.maxThumbTop) * drag.overflow : 0;
return { thumbTop, scrollTop };
}
/** Creation-mode scrollbar with a pointerdown-frozen drag mapping. */
export function useCreationTranscriptScrollbar({
enabled,
contentRevision,
scrollRef,
onScroll,
setScrollMode,
writeOffset,
finishProgrammaticScroll,
}: {
enabled: boolean;
contentRevision: number;
scrollRef: RefObject<HTMLDivElement | null>;
onScroll: () => void;
setScrollMode: (mode: TranscriptScrollMode, reason?: string) => void;
writeOffset: (owner: TranscriptScrollOwner, top: number, behavior?: ScrollBehavior) => boolean;
finishProgrammaticScroll: () => void;
}) {
const [state, setState] = useState<ScrollbarState>(HIDDEN_STATE);
const hotRef = useRef(false);
const dragRef = useRef<DragGeometry | null>(null);
const settleFrameRef = useRef<number | null>(null);
const setHot = useCallback((hot: boolean) => {
if (hotRef.current === hot) return;
hotRef.current = hot;
setState((previous) => previous.hot === hot ? previous : { ...previous, hot });
}, []);
const syncMetrics = useCallback(() => {
if (!enabled) return;
const element = scrollRef.current;
const geometry = element ? readGeometry(element) : null;
if (!element || !geometry) {
setState((previous) => previous.visible || previous.hot ? HIDDEN_STATE : previous);
return;
}
const drag = dragRef.current;
const overflow = drag?.overflow ?? geometry.overflow;
const maxThumbTop = drag?.maxThumbTop ?? geometry.maxThumbTop;
const thumbHeight = drag?.thumbHeight ?? geometry.thumbHeight;
const thumbTop = Math.round(Math.min(maxThumbTop, Math.max(0, (element.scrollTop / overflow) * maxThumbTop)));
setState((previous) => (
previous.visible
&& previous.hot === hotRef.current
&& previous.thumbTop === thumbTop
&& previous.thumbHeight === thumbHeight
) ? previous : { visible: true, hot: hotRef.current, thumbTop, thumbHeight });
}, [enabled, scrollRef]);
const finishDrag = useCallback((event?: PointerEvent) => {
const drag = dragRef.current;
if (!drag || (event && event.pointerId !== drag.pointerId)) return;
dragRef.current = null;
finishProgrammaticScroll();
syncMetrics();
const element = scrollRef.current;
if (!element || !event) {
setHot(false);
return;
}
const rect = element.getBoundingClientRect();
const fromRight = rect.right - event.clientX;
setHot(event.clientY >= rect.top && event.clientY <= rect.bottom && fromRight >= -2 && fromRight <= HOT_ZONE_PX);
}, [finishProgrammaticScroll, scrollRef, setHot, syncMetrics]);
useEffect(() => {
if (!enabled) {
dragRef.current = null;
hotRef.current = false;
setState(HIDDEN_STATE);
return;
}
const onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current;
const element = scrollRef.current;
if (drag && element && event.pointerId === drag.pointerId) {
const { thumbTop, scrollTop } = mapFrozenScrollbarDrag(drag, event.clientY);
writeOffset("custom-scrollbar", scrollTop);
setState({ visible: true, hot: true, thumbTop: Math.round(thumbTop), thumbHeight: drag.thumbHeight });
setHot(true);
return;
}
if (!element || !readGeometry(element)) {
setHot(false);
return;
}
const rect = element.getBoundingClientRect();
const fromRight = rect.right - event.clientX;
setHot(event.clientY >= rect.top && event.clientY <= rect.bottom && fromRight >= -2 && fromRight <= HOT_ZONE_PX);
};
const onPointerUp = (event: PointerEvent) => finishDrag(event);
const onBlur = () => finishDrag();
syncMetrics();
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerup", onPointerUp, { passive: true });
window.addEventListener("pointercancel", onPointerUp, { passive: true });
window.addEventListener("blur", onBlur);
window.addEventListener("resize", syncMetrics);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
window.removeEventListener("blur", onBlur);
window.removeEventListener("resize", syncMetrics);
if (settleFrameRef.current !== null) cancelAnimationFrame(settleFrameRef.current);
settleFrameRef.current = null;
dragRef.current = null;
hotRef.current = false;
};
}, [enabled, finishDrag, scrollRef, setHot, syncMetrics, writeOffset]);
const handleScroll = useCallback(() => {
onScroll();
if (enabled) syncMetrics();
}, [enabled, onScroll, syncMetrics]);
useLayoutEffect(() => {
if (enabled) syncMetrics();
}, [contentRevision, enabled, syncMetrics]);
useEffect(() => {
const element = scrollRef.current;
if (!enabled || !element) return;
return observeScrollContentSize(element, syncMetrics);
}, [enabled, scrollRef, syncMetrics]);
const onThumbPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled) return;
const element = scrollRef.current;
const geometry = element ? readGeometry(element) : null;
if (!element || !geometry) return;
event.preventDefault();
event.stopPropagation();
const startThumbTop = (element.scrollTop / geometry.overflow) * geometry.maxThumbTop;
dragRef.current = { pointerId: event.pointerId, startY: event.clientY, startThumbTop, ...geometry };
setScrollMode("programmatic", "custom-scrollbar-drag");
event.currentTarget.setPointerCapture(event.pointerId);
setHot(true);
}, [enabled, scrollRef, setHot, setScrollMode]);
const onRailPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled || (event.target as HTMLElement | null)?.closest?.(".transcript__scrollbar-thumb")) return;
const element = scrollRef.current;
const geometry = element ? readGeometry(element) : null;
if (!element || !geometry) return;
const rect = element.getBoundingClientRect();
const thumbTop = Math.min(geometry.maxThumbTop, Math.max(0, event.clientY - rect.top - geometry.thumbHeight / 2));
setScrollMode("programmatic", "custom-scrollbar-rail");
writeOffset("custom-scrollbar", geometry.maxThumbTop > 0 ? (thumbTop / geometry.maxThumbTop) * geometry.overflow : 0);
setState({ visible: true, hot: true, thumbTop: Math.round(thumbTop), thumbHeight: geometry.thumbHeight });
setHot(true);
if (settleFrameRef.current !== null) cancelAnimationFrame(settleFrameRef.current);
settleFrameRef.current = requestAnimationFrame(() => {
settleFrameRef.current = null;
finishProgrammaticScroll();
syncMetrics();
});
}, [enabled, finishProgrammaticScroll, scrollRef, setHot, setScrollMode, syncMetrics, writeOffset]);
return { state, handleScroll, onThumbPointerDown, onRailPointerDown };
}
+308 -31
View File
@@ -1,14 +1,27 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, TouchEvent as ReactTouchEvent, WheelEvent as ReactWheelEvent } from "react";
import type {
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
TouchEvent as ReactTouchEvent,
UIEvent as ReactUIEvent,
WheelEvent as ReactWheelEvent,
} from "react";
import { DUR_FAST, prefersReducedMotion } from "./motion";
import { isEditableTarget } from "./keyboardShortcuts";
import {
canTranscriptScrollOwnerWrite,
isTranscriptSelectionMode,
type TranscriptScrollMode,
type TranscriptScrollOwner,
type TranscriptViewportAnchor,
} from "./transcriptScrollController";
import {
canTranscriptScrollOwnerWriteNow,
canVirtualizerAdjustScroll,
canScrollEndSettle,
isUserGestureActive,
noteUserGesture,
type TranscriptUserScrollSource,
} from "./transcriptScrollSession";
declare global {
interface Window {
@@ -17,7 +30,9 @@ declare global {
}
const BOTTOM_THRESHOLD_PX = 80;
const BOTTOM_REENGAGE_PX = 0.5;
const TOUCH_SCROLL_THRESHOLD_PX = 2;
const PROGRAMMATIC_SCROLL_EVENT_HOLD_MS = 96;
const SCROLL_BREAK_KEYS = new Set([
"ArrowUp",
"PageUp",
@@ -30,21 +45,38 @@ const CONDITIONAL_SCROLL_KEYS = new Set([
" ",
"Spacebar",
]);
const PASSIVE_TAIL_OWNERS = new Set<TranscriptScrollOwner>([
"stream",
"container-resize",
"footer-resize",
"row-size",
]);
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX;
}
function isAtPhysicalBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_REENGAGE_PX;
}
function isScrollable(el: HTMLElement): boolean {
return el.scrollHeight - el.clientHeight > 1;
}
/** Cancel an in-flight smooth scroll without going through owner arbitration. */
function cancelInFlightSmoothScroll(el: HTMLElement) {
if (typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollTop, behavior: "auto" });
}
/**
* useScrollManager — frame-batched auto-scroll for the transcript container.
*
* - Auto-pins to the bottom when content is near the edge.
* - Smooth scroll for jump-to-question navigation.
* - Batches ResizeObserver callbacks into a single animation frame.
* - Holds a short user-gesture lock so virtualizer/stream writes cannot fight
* native trackpad inertia (stalls/jumps in long transcripts).
*/
export function useScrollManager() {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -58,28 +90,119 @@ export function useScrollManager() {
const touchStartY = useRef<number | null>(null);
const lastClientHeight = useRef<number | null>(null);
const lastFooterHeight = useRef<number | null>(null);
/** Epoch ms until which compensating writers must stay silent. */
const gestureUntilRef = useRef(0);
const gestureSourceRef = useRef<TranscriptUserScrollSource | null>(null);
const gestureLastActivityRef = useRef(0);
const gestureIdleTimerRef = useRef<number | null>(null);
const gestureIdleListenersRef = useRef(new Set<() => void>());
const deferredTailRepinRef = useRef<{
generation: number;
owner: TranscriptScrollOwner;
} | null>(null);
const programmaticScrollRef = useRef<{
generation: number;
owner: TranscriptScrollOwner;
target: number;
behavior: ScrollBehavior;
expiresAt: number;
} | null>(null);
// Near-bottom is a layout tolerance, not user intent. Once the reader moves
// upward, keep tail-follow suppressed until they explicitly return to the
// physical bottom (or use an explicit bottom/reset action).
const tailFollowSuppressedRef = useRef(false);
const [isAtBottom, setIsAtBottom] = useState(true);
const modeRef = useRef<TranscriptScrollMode>("tail-follow");
const generationRef = useRef(0);
const flushGestureIdleListeners = useCallback(() => {
if (isUserGestureActive(gestureUntilRef.current)) return;
for (const listener of gestureIdleListenersRef.current) {
try {
listener();
} catch {
// Listener failures must not break scroll ownership.
}
}
}, []);
const clearGesture = useCallback((notify: boolean) => {
gestureUntilRef.current = 0;
gestureSourceRef.current = null;
gestureLastActivityRef.current = 0;
if (gestureIdleTimerRef.current !== null) {
clearTimeout(gestureIdleTimerRef.current);
gestureIdleTimerRef.current = null;
}
const element = scrollRef.current;
if (element) delete element.dataset.scrollGesture;
if (notify) flushGestureIdleListeners();
}, [flushGestureIdleListeners]);
const scheduleGestureIdle = useCallback(() => {
if (gestureIdleTimerRef.current !== null) {
clearTimeout(gestureIdleTimerRef.current);
gestureIdleTimerRef.current = null;
}
const delay = Math.max(0, gestureUntilRef.current - Date.now()) + 16;
gestureIdleTimerRef.current = window.setTimeout(() => {
gestureIdleTimerRef.current = null;
if (isUserGestureActive(gestureUntilRef.current)) {
scheduleGestureIdle();
return;
}
clearGesture(true);
}, delay);
}, [clearGesture]);
useEffect(() => {
return () => {
if (resizeFrame.current !== null) cancelAnimationFrame(resizeFrame.current);
if (repinFrame.current !== null) cancelAnimationFrame(repinFrame.current);
if (smoothScrollTimer.current !== null) clearTimeout(smoothScrollTimer.current);
if (gestureIdleTimerRef.current !== null) clearTimeout(gestureIdleTimerRef.current);
for (const frame of layoutScrollFrames.current) cancelAnimationFrame(frame);
layoutScrollFrames.current = [];
gestureIdleListenersRef.current.clear();
};
}, []);
const updateBottomState = useCallback((el: HTMLElement) => {
const atBottom = isNearBottom(el);
stick.current = atBottom;
setIsAtBottom(atBottom);
if (!isTranscriptSelectionMode(modeRef.current)) {
modeRef.current = atBottom ? "tail-follow" : "manual";
const markUserGesture = useCallback((source: TranscriptUserScrollSource = "native-scroll") => {
const now = Date.now();
gestureUntilRef.current = noteUserGesture(now);
gestureLastActivityRef.current = now;
gestureSourceRef.current = source;
const element = scrollRef.current;
if (element) element.dataset.scrollGesture = source;
scheduleGestureIdle();
}, [scheduleGestureIdle]);
const finishUserGesture = useCallback(() => {
if (gestureUntilRef.current === 0 && gestureSourceRef.current === null) return;
clearGesture(true);
}, [clearGesture]);
/**
* Subscribe to the first quiet frame after a user scroll gesture ends.
* Used to batch virtualizer.measure() so remount heights settle without
* fighting trackpad inertia mid-gesture.
*/
const onGestureIdle = useCallback((listener: () => void) => {
gestureIdleListenersRef.current.add(listener);
return () => {
gestureIdleListenersRef.current.delete(listener);
};
}, []);
const updateBottomState = useCallback((el: HTMLElement, preserveMode = false) => {
const shouldFollow = isNearBottom(el) && !tailFollowSuppressedRef.current;
stick.current = shouldFollow;
setIsAtBottom(shouldFollow);
if (!preserveMode && !isTranscriptSelectionMode(modeRef.current)) {
modeRef.current = shouldFollow ? "tail-follow" : "manual";
el.dataset.scrollMode = modeRef.current;
}
return atBottom;
return shouldFollow;
}, []);
const cancelPendingBottomScroll = useCallback(() => {
@@ -92,23 +215,79 @@ export function useScrollManager() {
repinFrame.current = null;
}
pendingRepinHeightDelta.current = 0;
deferredTailRepinRef.current = null;
for (const frame of layoutScrollFrames.current) cancelAnimationFrame(frame);
layoutScrollFrames.current = [];
}, []);
const setMode = useCallback((mode: TranscriptScrollMode, _reason?: string) => {
if (mode === "tail-follow") tailFollowSuppressedRef.current = false;
else deferredTailRepinRef.current = null;
modeRef.current = mode;
if (scrollRef.current) scrollRef.current.dataset.scrollMode = mode;
if (isTranscriptSelectionMode(mode)) cancelPendingBottomScroll();
}, [cancelPendingBottomScroll]);
const deferTailRepin = useCallback((owner: TranscriptScrollOwner, now: number) => {
if (!PASSIVE_TAIL_OWNERS.has(owner)) return false;
if (!isUserGestureActive(gestureUntilRef.current, now)) return false;
if (modeRef.current !== "tail-follow" || !stick.current || tailFollowSuppressedRef.current) return false;
deferredTailRepinRef.current = { generation: generationRef.current, owner };
return true;
}, []);
const writeOffset = useCallback((owner: TranscriptScrollOwner, top: number, behavior: ScrollBehavior = "auto") => {
const el = scrollRef.current;
if (!el || !canTranscriptScrollOwnerWrite(modeRef.current, owner)) return false;
if (!el) return false;
const now = Date.now();
if (!canTranscriptScrollOwnerWriteNow(modeRef.current, owner, gestureUntilRef.current, now)) {
deferTailRepin(owner, now);
return false;
}
if (owner === "jump" || owner === "rewind" || owner === "jump-bottom" || owner === "custom-scrollbar") {
cancelPendingBottomScroll();
clearGesture(false);
}
const target = Math.max(0, Math.min(top, Math.max(0, el.scrollHeight - el.clientHeight)));
if (owner === "custom-scrollbar") {
tailFollowSuppressedRef.current = Math.max(0, el.scrollHeight - el.clientHeight) - target > BOTTOM_REENGAGE_PX;
} else if (owner === "jump-bottom") {
tailFollowSuppressedRef.current = false;
}
programmaticScrollRef.current = {
generation: generationRef.current,
owner,
target,
behavior,
expiresAt: Date.now() + (behavior === "smooth" ? DUR_FAST * 2 * 1000 : PROGRAMMATIC_SCROLL_EVENT_HOLD_MS),
};
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__?.(owner, top);
if (typeof el.scrollTo === "function") el.scrollTo({ top, behavior });
else el.scrollTop = top;
return true;
}, [cancelPendingBottomScroll, clearGesture, deferTailRepin]);
useEffect(() => onGestureIdle(() => {
const pending = deferredTailRepinRef.current;
deferredTailRepinRef.current = null;
const el = scrollRef.current;
if (!pending || !el || pending.generation !== generationRef.current) return;
if (modeRef.current !== "tail-follow" || !stick.current || tailFollowSuppressedRef.current) return;
writeOffset(pending.owner, el.scrollHeight);
}), [onGestureIdle, writeOffset]);
const consumeProgrammaticScroll = useCallback((top: number) => {
const marker = programmaticScrollRef.current;
if (!marker || marker.generation !== generationRef.current || marker.expiresAt < Date.now()) {
programmaticScrollRef.current = null;
return false;
}
if (marker.behavior === "smooth") return true;
if (Math.abs(marker.target - top) <= 1) {
programmaticScrollRef.current = null;
return true;
}
return false;
}, []);
const releaseAutoScroll = useCallback(() => {
@@ -118,13 +297,16 @@ export function useScrollManager() {
clearTimeout(smoothScrollTimer.current);
smoothScrollTimer.current = null;
}
// A same-position instant scroll cancels an in-flight native smooth scroll.
if (el) writeOffset("virtualizer", el.scrollTop);
// Cancel in-flight smooth scrolling without owner arbitration so a mid-gesture
// release is never blocked by the gesture lock itself.
if (el) cancelInFlightSmoothScroll(el);
cancelPendingBottomScroll();
tailFollowSuppressedRef.current = true;
stick.current = false;
setIsAtBottom(false);
modeRef.current = "manual";
}, [cancelPendingBottomScroll, writeOffset]);
if (scrollRef.current) scrollRef.current.dataset.scrollMode = "manual";
}, [cancelPendingBottomScroll]);
const onWheelIntent = useCallback((event: ReactWheelEvent<HTMLElement>) => {
const el = scrollRef.current;
@@ -132,12 +314,16 @@ export function useScrollManager() {
// macOS/Chrome), not a scroll — treating it as scroll intent would release
// tail-follow on a zoom that never actually moved scrollTop.
if (!el || isTranscriptSelectionMode(modeRef.current) || !isScrollable(el) || event.ctrlKey || event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return false;
// Any vertical wheel on a scrollable transcript starts the gesture lock so
// virtualizer remounts cannot rewrite scrollTop mid-inertia — including
// wheel-down at the bottom (tail-follow stays, compensation still freezes).
markUserGesture("wheel");
if (event.deltaY < 0 || !isNearBottom(el)) {
releaseAutoScroll();
return true;
}
return false;
}, [releaseAutoScroll]);
}, [markUserGesture, releaseAutoScroll]);
const onTouchStartIntent = useCallback((event: ReactTouchEvent<HTMLElement>) => {
touchStartY.current = event.touches[0]?.clientY ?? null;
@@ -150,12 +336,13 @@ export function useScrollManager() {
if (!el || isTranscriptSelectionMode(modeRef.current) || !isScrollable(el) || startY === null || currentY === undefined) return false;
const deltaY = currentY - startY;
if (Math.abs(deltaY) < TOUCH_SCROLL_THRESHOLD_PX) return false;
markUserGesture("touch");
if (deltaY > 0 || !isNearBottom(el)) {
releaseAutoScroll();
return true;
}
return false;
}, [releaseAutoScroll]);
}, [markUserGesture, releaseAutoScroll]);
const onKeyScrollIntent = useCallback((event: ReactKeyboardEvent<HTMLElement>) => {
const el = scrollRef.current;
@@ -166,15 +353,73 @@ export function useScrollManager() {
// on a completely unrelated stream, even though nothing was scrolled.
if (!el || isTranscriptSelectionMode(modeRef.current) || !isScrollable(el) || isEditableTarget(event.target)) return false;
if (SCROLL_BREAK_KEYS.has(event.key) || (CONDITIONAL_SCROLL_KEYS.has(event.key) && !isNearBottom(el))) {
markUserGesture("keyboard");
releaseAutoScroll();
return true;
}
return false;
}, [releaseAutoScroll]);
}, [markUserGesture, releaseAutoScroll]);
const onScroll = useCallback(() => {
const onPointerDownIntent = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const el = scrollRef.current;
if (el) updateBottomState(el);
if (event.button !== 1 || !el || !isScrollable(el) || isTranscriptSelectionMode(modeRef.current)) return false;
releaseAutoScroll();
markUserGesture("middle-button");
return true;
}, [markUserGesture, releaseAutoScroll]);
const onNestedScrollIntent = useCallback((deltaY: number) => {
const el = scrollRef.current;
if (!el || isTranscriptSelectionMode(modeRef.current) || !isScrollable(el) || deltaY === 0) return false;
markUserGesture("nested-scroll");
if (deltaY < 0 || !isNearBottom(el)) {
releaseAutoScroll();
return true;
}
return false;
}, [markUserGesture, releaseAutoScroll]);
const onScroll = useCallback((event?: ReactUIEvent<HTMLElement>) => {
const el = scrollRef.current;
if (!el) return;
const programmatic = consumeProgrammaticScroll(el.scrollTop) || isTranscriptSelectionMode(modeRef.current);
if (programmatic) {
updateBottomState(el, true);
return;
}
if (event?.nativeEvent.isTrusted === false && !isUserGestureActive(gestureUntilRef.current)) {
// Test harnesses and integration code sometimes dispatch a synthetic
// scroll after positioning the viewport. It is not user ownership.
updateBottomState(el);
return;
}
// Native scrollbar drags and Windows middle-button auto-scroll can emit
// scroll events without wheel samples. Treat every unowned scroll as user
// activity; controller-owned writes are consumed above.
markUserGesture(gestureSourceRef.current ?? "native-scroll");
tailFollowSuppressedRef.current = !isAtPhysicalBottom(el);
updateBottomState(el);
if (tailFollowSuppressedRef.current) cancelPendingBottomScroll();
}, [cancelPendingBottomScroll, consumeProgrammaticScroll, markUserGesture, updateBottomState]);
const onScrollEnd = useCallback(() => {
// WebViews can dispatch scrollend after a controller-owned write or after
// the preceding native gesture was already settled. It is only a hint for
// an existing user session; never manufacture a new idle cycle from it.
if (gestureUntilRef.current === 0 || gestureSourceRef.current === null) return;
if (!canScrollEndSettle(gestureLastActivityRef.current)) {
scheduleGestureIdle();
return;
}
finishUserGesture();
}, [finishUserGesture, scheduleGestureIdle]);
const finishProgrammaticScroll = useCallback(() => {
programmaticScrollRef.current = null;
const el = scrollRef.current;
if (!el || isTranscriptSelectionMode(modeRef.current)) return;
modeRef.current = "manual";
updateBottomState(el);
}, [updateBottomState]);
/** Scroll smoothly to a specific element. Used by the JumpBar. */
@@ -184,6 +429,7 @@ export function useScrollManager() {
stick.current = false;
setIsAtBottom(false);
modeRef.current = "programmatic";
if (scrollRef.current) scrollRef.current.dataset.scrollMode = "programmatic";
if (resizeFrame.current !== null) {
cancelAnimationFrame(resizeFrame.current);
resizeFrame.current = null;
@@ -195,23 +441,25 @@ export function useScrollManager() {
const target = Math.max(0, top);
writeOffset("jump", target, reduced ? "auto" : "smooth");
if (smoothScrollTimer.current !== null) clearTimeout(smoothScrollTimer.current);
if (reduced) updateBottomState(el);
if (reduced) finishProgrammaticScroll();
else {
const generation = generationRef.current;
smoothScrollTimer.current = window.setTimeout(() => {
smoothScrollTimer.current = null;
if (generation !== generationRef.current) return;
updateBottomState(el);
finishProgrammaticScroll();
}, DUR_FAST * 2 * 1000);
}
}, [updateBottomState, writeOffset]);
}, [finishProgrammaticScroll, writeOffset]);
/** Force-scroll to the bottom — used when a new question is sent. */
const scrollToBottom = useCallback((force = false, owner: TranscriptScrollOwner = "stream") => {
const el = scrollRef.current;
if (!el || !canTranscriptScrollOwnerWrite(modeRef.current, owner)) return;
if (!el || isTranscriptSelectionMode(modeRef.current)) return;
if (force) {
tailFollowSuppressedRef.current = false;
modeRef.current = "tail-follow";
if (scrollRef.current) scrollRef.current.dataset.scrollMode = "tail-follow";
stick.current = true;
setIsAtBottom(true);
}
@@ -237,7 +485,9 @@ export function useScrollManager() {
const snapToBottom = useCallback((owner: TranscriptScrollOwner = "jump-bottom") => {
const el = scrollRef.current;
if (!el) return;
if (!el || isTranscriptSelectionMode(modeRef.current)) return;
const explicit = owner === "jump-bottom";
if (!explicit && (modeRef.current !== "tail-follow" || !stick.current || tailFollowSuppressedRef.current)) return;
if (resizeFrame.current !== null) {
cancelAnimationFrame(resizeFrame.current);
resizeFrame.current = null;
@@ -246,9 +496,13 @@ export function useScrollManager() {
clearTimeout(smoothScrollTimer.current);
smoothScrollTimer.current = null;
}
stick.current = true;
writeOffset(owner, el.scrollHeight);
setIsAtBottom(true);
if (explicit) {
tailFollowSuppressedRef.current = false;
modeRef.current = "tail-follow";
el.dataset.scrollMode = "tail-follow";
stick.current = true;
}
if (writeOffset(owner, el.scrollHeight)) setIsAtBottom(true);
}, [writeOffset]);
const scrollToBottomAfterLayout = useCallback((frames = 4, owner: TranscriptScrollOwner = "jump-bottom") => {
@@ -272,8 +526,11 @@ export function useScrollManager() {
/** Call when a new question is submitted — overrides stick state. */
const onNewQuestion = useCallback(() => {
stick.current = true;
// A new send is an explicit user action; clear any residual gesture lock so
// jump-bottom is not deferred by the previous scroll session.
clearGesture(false);
scrollToBottom(true, "jump-bottom");
}, [scrollToBottom]);
}, [clearGesture, scrollToBottom]);
/**
* Refresh pin state on resize — call from a ResizeObserver on the container.
@@ -282,6 +539,7 @@ export function useScrollManager() {
(containerHeightDelta: number, owner: TranscriptScrollOwner = "container-resize") => {
const el = scrollRef.current;
if (!el) return;
if (tailFollowSuppressedRef.current) return;
const bottomDistance = el.scrollHeight - el.scrollTop - el.clientHeight;
if (!stick.current && bottomDistance + containerHeightDelta >= BOTTOM_THRESHOLD_PX) return;
stick.current = true;
@@ -299,7 +557,7 @@ export function useScrollManager() {
repinFrame.current = null;
const delta = pendingRepinHeightDelta.current;
pendingRepinHeightDelta.current = 0;
if (canTranscriptScrollOwnerWrite(modeRef.current, owner)) repinIfWasPinned(delta, owner);
repinIfWasPinned(delta, owner);
});
},
[repinIfWasPinned],
@@ -308,21 +566,27 @@ export function useScrollManager() {
const resetGeneration = useCallback((_tabId?: string, _revealSignal?: number) => {
generationRef.current += 1;
cancelPendingBottomScroll();
clearGesture(false);
programmaticScrollRef.current = null;
if (smoothScrollTimer.current !== null) {
clearTimeout(smoothScrollTimer.current);
smoothScrollTimer.current = null;
const el = scrollRef.current;
modeRef.current = "programmatic";
if (el) writeOffset("virtualizer", el.scrollTop);
if (el) cancelInFlightSmoothScroll(el);
}
modeRef.current = "tail-follow";
tailFollowSuppressedRef.current = false;
if (scrollRef.current) scrollRef.current.dataset.scrollMode = "tail-follow";
stick.current = true;
setIsAtBottom(true);
return generationRef.current;
}, [cancelPendingBottomScroll, writeOffset]);
}, [cancelPendingBottomScroll, clearGesture]);
const canVirtualizerAdjust = useCallback(() => !isTranscriptSelectionMode(modeRef.current), []);
const canVirtualizerAdjust = useCallback(
() => canVirtualizerAdjustScroll(modeRef.current, gestureUntilRef.current),
[],
);
const captureViewportAnchor = useCallback((): TranscriptViewportAnchor | null => {
const el = scrollRef.current;
@@ -340,6 +604,8 @@ export function useScrollManager() {
const reconcileViewportAnchor = useCallback((snapshot: TranscriptViewportAnchor | null) => {
const el = scrollRef.current;
if (!el || !snapshot || snapshot.generation !== generationRef.current) return false;
// Never fight an active trackpad gesture with anchor reconciliation.
if (!canVirtualizerAdjustScroll(modeRef.current, gestureUntilRef.current)) return false;
const row = Array.from(el.querySelectorAll<HTMLElement>(".transcript__row[data-row-key]"))
.find((candidate) => candidate.dataset.rowKey === snapshot.rowKey);
if (!row) return false;
@@ -370,7 +636,10 @@ export function useScrollManager() {
scrollRef,
stick,
onScroll,
onScrollEnd,
onWheelIntent,
onPointerDownIntent,
onNestedScrollIntent,
onTouchStartIntent,
onTouchMoveIntent,
onKeyScrollIntent,
@@ -392,5 +661,13 @@ export function useScrollManager() {
canVirtualizerAdjust,
captureViewportAnchor,
reconcileViewportAnchor,
/** Marks an external user scroll intent (e.g. nested-edge handoff). */
markUserGesture,
/** Fires once after the gesture hold expires (idle remeasure hook). */
onGestureIdle,
gestureUntilRef,
gestureSourceRef,
gestureLastActivityRef,
finishProgrammaticScroll,
};
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, type MutableRefObject, type RefObject } from "react";
import { useCallback, useEffect, useRef, type MutableRefObject, type RefObject } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
import { readTranscriptLayoutSnapshot, type TranscriptLayoutSnapshot } from "./transcriptHeightCache";
import type { TranscriptViewportAnchor } from "./transcriptScrollController";
/**
* Invalidates TanStack's in-memory measurements when transcript width or root
@@ -12,21 +13,50 @@ export function useTranscriptMeasurementInvalidation({
layoutSnapshotRef,
virtualizer,
selectionActive,
canMeasure,
onMeasureIdle,
captureViewportAnchor,
reconcileViewportAnchor,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
layoutSnapshotRef: MutableRefObject<TranscriptLayoutSnapshot>;
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
selectionActive: boolean;
canMeasure: () => boolean;
onMeasureIdle: (listener: () => void) => () => void;
captureViewportAnchor: () => TranscriptViewportAnchor | null;
reconcileViewportAnchor: (snapshot: TranscriptViewportAnchor | null) => boolean;
}) {
const activeRef = useRef(selectionActive);
const pendingRef = useRef(false);
const reconcileFrameRef = useRef<number | null>(null);
activeRef.current = selectionActive;
useEffect(() => {
if (selectionActive || !pendingRef.current) return;
const flushPending = useCallback(() => {
if (!pendingRef.current || activeRef.current || !canMeasure()) return;
pendingRef.current = false;
const anchor = captureViewportAnchor();
virtualizer.measure();
}, [selectionActive, virtualizer]);
if (reconcileFrameRef.current !== null) cancelAnimationFrame(reconcileFrameRef.current);
reconcileFrameRef.current = requestAnimationFrame(() => {
reconcileFrameRef.current = null;
if (activeRef.current || !canMeasure()) {
pendingRef.current = true;
return;
}
reconcileViewportAnchor(anchor);
});
}, [canMeasure, captureViewportAnchor, reconcileViewportAnchor, virtualizer]);
useEffect(() => {
if (!selectionActive) flushPending();
}, [flushPending, selectionActive]);
useEffect(() => onMeasureIdle(flushPending), [flushPending, onMeasureIdle]);
useEffect(() => () => {
if (reconcileFrameRef.current !== null) cancelAnimationFrame(reconcileFrameRef.current);
}, []);
useEffect(() => {
const element = scrollRef.current;
@@ -35,18 +65,15 @@ export function useTranscriptMeasurementInvalidation({
const initialChanged = initial.signature !== layoutSnapshotRef.current.signature;
layoutSnapshotRef.current = initial;
if (initialChanged) {
if (activeRef.current) pendingRef.current = true;
else virtualizer.measure();
pendingRef.current = true;
flushPending();
}
const invalidateIfChanged = () => {
const next = readTranscriptLayoutSnapshot(element);
if (next.signature === layoutSnapshotRef.current.signature) return;
layoutSnapshotRef.current = next;
if (activeRef.current) {
pendingRef.current = true;
return;
}
virtualizer.measure();
pendingRef.current = true;
flushPending();
};
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(invalidateIfChanged);
resizeObserver?.observe(element);
@@ -59,5 +86,5 @@ export function useTranscriptMeasurementInvalidation({
mutationObserver?.disconnect();
window.removeEventListener("resize", invalidateIfChanged);
};
}, [layoutSnapshotRef, scrollRef, virtualizer]);
}, [flushPending, layoutSnapshotRef, scrollRef]);
}
@@ -0,0 +1,57 @@
import { useCallback, useEffect, type KeyboardEvent, type PointerEvent, type RefObject, type TouchEvent, type WheelEvent } from "react";
import { attachNestedScrollHandoff } from "./nestedScrollHandoff";
export function useTranscriptScrollInteractions({
scrollRef,
cancelStreamingScroll,
onWheelIntent,
onTouchMoveIntent,
onKeyScrollIntent,
onPointerDownIntent,
onNestedScrollIntent,
onScrollEnd,
onSelectionPointerDown,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
cancelStreamingScroll: () => void;
onWheelIntent: (event: WheelEvent<HTMLElement>) => boolean;
onTouchMoveIntent: (event: TouchEvent<HTMLElement>) => boolean;
onKeyScrollIntent: (event: KeyboardEvent<HTMLElement>) => boolean;
onPointerDownIntent: (event: PointerEvent<HTMLElement>) => boolean;
onNestedScrollIntent: (deltaY: number) => boolean;
onScrollEnd: () => void;
onSelectionPointerDown: (event: PointerEvent<HTMLElement>) => void;
}) {
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
const onNestedIntent = (deltaY: number) => {
if (onNestedScrollIntent(deltaY)) cancelStreamingScroll();
};
const handoff = attachNestedScrollHandoff({ parent: element, onParentScrollIntent: onNestedIntent });
element.addEventListener("scrollend", onScrollEnd);
return () => {
handoff.detach();
element.removeEventListener("scrollend", onScrollEnd);
};
}, [cancelStreamingScroll, onNestedScrollIntent, onScrollEnd, scrollRef]);
const onWheelCapture = useCallback((event: WheelEvent<HTMLElement>) => {
if (onWheelIntent(event)) cancelStreamingScroll();
}, [cancelStreamingScroll, onWheelIntent]);
const onTouchMoveCapture = useCallback((event: TouchEvent<HTMLElement>) => {
if (onTouchMoveIntent(event)) cancelStreamingScroll();
}, [cancelStreamingScroll, onTouchMoveIntent]);
const onKeyDownCapture = useCallback((event: KeyboardEvent<HTMLElement>) => {
if (onKeyScrollIntent(event)) cancelStreamingScroll();
}, [cancelStreamingScroll, onKeyScrollIntent]);
const onPointerDownCapture = useCallback((event: PointerEvent<HTMLElement>) => {
if (onPointerDownIntent(event)) cancelStreamingScroll();
onSelectionPointerDown(event);
}, [cancelStreamingScroll, onPointerDownIntent, onSelectionPointerDown]);
return { onWheelCapture, onTouchMoveCapture, onKeyDownCapture, onPointerDownCapture };
}
+2
View File
@@ -2836,6 +2836,8 @@ export const en = {
"turnActions.summary": "Compress",
"turnActions.rewind": "Rewind",
"transcript.jumpToBottom": "Jump to bottom",
"markdown.tableExpandAll": "Expand all {n} rows",
"markdown.tableCollapse": "Collapse table",
"transcript.showEarlierHistory": "Show {n} earlier turns",
"transcript.toolCount": "{n} tools",
"transcript.thoughtCount": "{n} thoughts",
+2
View File
@@ -1937,6 +1937,8 @@ export const zhTW: Record<DictKey, string> = {
"turnActions.summary": "壓縮",
"turnActions.rewind": "回溯",
"transcript.jumpToBottom": "跳到底部",
"markdown.tableExpandAll": "展開全部 {n} 行",
"markdown.tableCollapse": "收起表格",
"transcript.showEarlierHistory": "展開前 {n} 輪對話",
"transcript.toolCount": "{n} 個工具",
"transcript.thoughtCount": "{n} 段思考",
+2
View File
@@ -2839,6 +2839,8 @@ export const zh: Record<DictKey, string> = {
"turnActions.summary": "压缩",
"turnActions.rewind": "回溯",
"transcript.jumpToBottom": "跳到底部",
"markdown.tableExpandAll": "展开全部 {n} 行",
"markdown.tableCollapse": "收起表格",
"transcript.showEarlierHistory": "展开前 {n} 轮对话",
"transcript.toolCount": "{n} 个工具",
"transcript.thoughtCount": "{n} 段思考",
+65 -4
View File
@@ -5110,12 +5110,63 @@ button.reasoning-summary:focus-visible {
border-radius: 5px;
padding: 1px 5px;
}
/* Horizontal overflow only: never create a vertical nested scroller. CSS
forces overflow-y to "auto" when overflow-x is non-visible, so tables use a
wrapper (below) for x-scroll and the table itself stays overflow:visible. */
.md-table-scroll {
display: block;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
margin: 0 0 12px;
}
/* Large tables default to an in-flow preview; expand mounts the virtual
scroller only when the user opts in (see MarkdownTable.tsx). */
.md-table-fold {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0 0 12px;
}
.md-table-fold > .md-table-scroll,
.md-table-fold > .md-table-virtual {
margin-bottom: 0;
}
.md-table-fold__toggle {
--wails-draggable: no-drag;
align-self: flex-start;
margin: 0;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-soft);
color: var(--fg-dim);
font-size: var(--text-xs, 12px);
line-height: 1.4;
cursor: pointer;
}
.md-table-fold__toggle:hover {
color: var(--fg);
border-color: color-mix(in srgb, var(--accent) 28%, var(--border));
background: color-mix(in srgb, var(--accent-soft) 40%, var(--bg-soft));
}
.md-table-fold__toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
outline-offset: 2px;
}
.md table {
border-collapse: collapse;
margin: 0 0 12px;
font-size: var(--font-content);
display: block;
overflow-x: auto;
width: 100%;
/* Keep tables in normal document flow so trackpad Y always drives .transcript.
Wide tables sit inside .md-table-scroll when a horizontal scroller is needed. */
overflow: visible;
}
.md-table-scroll > table {
margin-bottom: 0;
width: max-content;
min-width: 100%;
}
.md th,
.md td {
@@ -5130,10 +5181,12 @@ button.reasoning-summary:focus-visible {
/* Row-virtualized oversized markdown tables (see MarkdownTable.tsx): the
wrapper owns vertical scroll, the thead stays pinned, and body rows are
absolutely positioned by the virtualizer. Flex rows keep header/body cells
column-aligned without a full-table layout pass. */
column-aligned without a full-table layout pass. Marked data-nested-scroll
so transcript wheel handoff promotes edge gestures to .transcript. */
.md-table-virtual {
overflow-y: auto;
overflow-x: auto;
overscroll-behavior: contain;
margin: 0 0 12px;
border: 1px solid var(--border);
border-radius: 6px;
@@ -5176,10 +5229,18 @@ button.reasoning-summary:focus-visible {
font-family: var(--font-code-family);
font-size: var(--font-code);
line-height: 1.55;
overflow: auto;
/* Horizontal scroll only by default so trackpad Y drives the transcript.
Extremely tall blocks opt into nested Y via .code--scroll-y + handoff. */
overflow-x: auto;
overflow-y: hidden;
white-space: pre;
color: var(--code-fg, var(--fg));
}
.code--scroll-y {
overflow-y: auto;
overscroll-behavior: contain;
max-height: min(60vh, 28rem);
}
.code--loading {
opacity: 0.55;
}