mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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");
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 段思考",
|
||||
|
||||
@@ -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} 段思考",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user