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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
"test:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-live-turn-stability.test.tsx && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-reader-visual-guard-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-same-tab-tail-race.test.tsx && tsx src/__tests__/transcript-history-prepend-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-tail-clamp-race.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.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-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-geometry-replay.test.ts && tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-live-turn-stability.test.tsx && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-reader-visual-guard-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-same-tab-tail-race.test.tsx && tsx src/__tests__/transcript-history-prepend-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-tail-clamp-race.test.ts && tsx src/__tests__/transcript-scroll-writer.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/transcript-native-scrollbar-generation.test.tsx && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.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-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript-browser": "node bench/transcript-selection.mjs && node bench/transcript-scroll-stability.mjs && node bench/composer-transcript-stability.mjs",
"test:transcript-reader-browser": "node bench/transcript-reader-transaction.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && 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:diagnostics && pnpm test:transcript",
Expand Down
8 changes: 6 additions & 2 deletions desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ console.log("\nbundle budgets");
// Reading the applied item-list transform (instead of the remembered offset)
// keeps the reader/anchor visual guards from compounding under reduced-motion
// WebView2; the merged path measures 462.827 KiB. Retain one decimal step.
const initialJSBudgetKiB = 462.9;
// Generation-bound native-thumb transactions and the rebased custom-scrollbar
// drag add 0.3 KiB gzip; the merged path measures 463.102 KiB.
const initialJSBudgetKiB = 463.2;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
Expand Down Expand Up @@ -333,6 +335,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// ceiling; gzip and largest-chunk budgets remain unchanged.
// Reading the applied item-list transform for the reader/anchor visual guards
// adds 0.5 KiB raw on top; the merged path measures 2469.815 KiB.
const rawInitialBudgetKiB = 2_469.9;
// The scrollbar generation fence and drag rebase add 1.1 KiB raw; the merged
// path measures 2470.932 KiB.
const rawInitialBudgetKiB = 2_471.0;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {
mapFrozenScrollbarDrag,
rebaseFrozenScrollbarDrag,
readCreationScrollbarGeometry,
} from "../lib/useCreationTranscriptScrollbar";

Expand Down Expand Up @@ -36,12 +37,16 @@ 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.
// Content can grow while virtual rows mount. Rebase the active drag at the
// current physical position so the next pointer move cannot write through a
// stale extent while preserving the thumb's visible position.
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");
const rebased = rebaseFrozenScrollbarDrag(drag, grown, 2_100, 200);
eq(rebased.startY, 200, "geometry rebase pins the drag to the latest pointer sample");
eq(rebased.startThumbTop, 210, "geometry rebase preserves the current physical scroll ratio");
eq(mapFrozenScrollbarDrag(rebased, 300).scrollTop, 3_100, "subsequent drag movement uses the current extent");

eq(mapFrozenScrollbarDrag(drag, -1_000).thumbTop, 0, "drag clamps at the top");
eq(mapFrozenScrollbarDrag(drag, 2_000).thumbTop, 640, "drag clamps at the bottom");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Run: tsx src/__tests__/transcript-native-scrollbar-generation.test.tsx
//
// A native-thumb transaction is bound to the surface generation it started
// in. Callers that advance the generation are expected to cancel it first,
// but the hook must not depend on that ordering: a late observe/finish from
// a previous generation must clean up silently instead of reusing the stale
// element or claiming the tail.

import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { TranscriptScrollEvent, TranscriptScrollMode } from "../lib/transcriptScrollArbiter";
import type { TranscriptTailSettle } from "../lib/transcriptTailSettle";
import { useTranscriptNativeScrollbarOwnership } from "../lib/useTranscriptNativeScrollbarOwnership";

let passed = 0;
let failed = 0;

function check(condition: unknown, label: string) {
if (condition) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}

console.log("\ntranscript native scrollbar generation fence");

const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost/",
});
(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.Element = dom.window.Element;
globalThis.Node = dom.window.Node;

const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 600 });
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 6_000 });
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 1_000 });

const scrollRef = { current: scrollElement as HTMLDivElement | null };
const modeRef = { current: "manual" as TranscriptScrollMode };
const generationRef = { current: 7 };
const events: TranscriptScrollEvent[] = [];
let readerCancels = 0;
let deliveries = 0;
let tailSchedules = 0;
const tailSettle = {
schedule: () => { tailSchedules += 1; },
cancel: () => {},
} as unknown as TranscriptTailSettle;

let ownership: ReturnType<typeof useTranscriptNativeScrollbarOwnership> | undefined;
function Probe() {
ownership = useTranscriptNativeScrollbarOwnership({
scrollRef,
modeRef,
cancelReaderTransaction: () => { readerCancels += 1; },
deliverScroll: () => { deliveries += 1; },
dispatch: (event) => { events.push(event); return undefined; },
tailSettle,
generationRef,
});
return null;
}

const root = createRoot(dom.window.document.getElementById("root")!);
await act(async () => root.render(<Probe />));

// Same generation: the transaction observes progress and finishes normally.
await act(async () => ownership!.begin(1, scrollElement));
check(readerCancels === 1, "beginning a thumb drag ends the reader transaction");
check(events.at(-1)?.type === "NATIVE_SCROLLBAR_BEGIN", "beginning a thumb drag dispatches NATIVE_SCROLLBAR_BEGIN");
check(scrollElement.dataset.nativeScrollbarDrag === "true", "the element carries the drag marker");
check(ownership!.isActive() === true, "the transaction is active within its generation");
scrollElement.scrollTop = 5_400;
await act(async () => ownership!.observe(scrollElement));
events.length = 0;
await act(async () => { ownership!.finish(1); });
check(deliveries === 1, "a same-generation finish delivers the final scroll sample");
check(events.some((event) => event.type === "NATIVE_SCROLLBAR_END" && event.claimTail === true),
"forward progress to the bottom lets the same-generation finish claim the tail");
check(scrollElement.dataset.nativeScrollbarDrag === undefined, "a same-generation finish clears the drag marker");

// Generation advances underneath the transaction without a cancel.
events.length = 0;
deliveries = 0;
tailSchedules = 0;
scrollElement.scrollTop = 1_000;
await act(async () => ownership!.begin(2, scrollElement));
generationRef.current += 1;
events.length = 0;
check(ownership!.isActive() === false, "a transaction from a previous generation is no longer active");
scrollElement.scrollTop = 5_400;
await act(async () => ownership!.observe(scrollElement));
check(scrollElement.dataset.nativeScrollbarDrag === undefined,
"a stale-generation observe clears the drag marker instead of recording progress");
check(events.length === 1 && events[0].type === "NATIVE_SCROLLBAR_END" && events[0].claimTail === false,
"a stale-generation observe ends native ownership without claiming the tail");
let finished: boolean | undefined;
await act(async () => { finished = ownership!.finish(2); });
check(finished === false, "a stale-generation finish reports no transaction");
check(deliveries === 0 && tailSchedules === 0,
"a stale-generation finish neither delivers a sample nor schedules tail settle");

// Finish before any observe on a stale generation: still silent cleanup.
events.length = 0;
scrollElement.scrollTop = 1_000;
await act(async () => ownership!.begin(3, scrollElement));
generationRef.current += 1;
await act(async () => { finished = ownership!.finish(3); });
check(finished === false && scrollElement.dataset.nativeScrollbarDrag === undefined,
"a stale-generation finish without observe clears the marker and reports no transaction");
check(events.every((event) => event.type !== "NATIVE_SCROLLBAR_END" || event.claimTail === false),
"no stale-generation path can claim the tail");

await act(async () => root.unmount());
dom.window.close();

if (failed > 0) {
console.error(`\n${failed} transcript native scrollbar generation test(s) failed; ${passed} passed.`);
process.exit(1);
}
console.log(`\n${passed} transcript native scrollbar generation tests passed.`);
55 changes: 52 additions & 3 deletions desktop/frontend/src/lib/useCreationTranscriptScrollbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import type { TranscriptScrollMode, TranscriptScrollOwner } from "./transcriptSc

const HOT_ZONE_PX = 18;
const MIN_THUMB_PX = 28;
// A rail click lands while rows may still be measuring. Wait for quiet
// geometry before releasing programmatic ownership, but never past the same
// wall-clock budget the arbiter grants slow WebView2 row mounts.
const RAIL_SETTLE_STABLE_FRAMES = 2;
const RAIL_SETTLE_BUDGET_MS = 1_000;

type ScrollbarState = {
visible: boolean;
Expand All @@ -23,6 +28,7 @@ type ScrollbarState = {
type DragGeometry = {
pointerId: number;
startY: number;
lastClientY: number;
startThumbTop: number;
overflow: number;
maxThumbTop: number;
Expand Down Expand Up @@ -52,6 +58,25 @@ export function mapFrozenScrollbarDrag(
return { thumbTop, scrollTop };
}

/** Rebase an active drag after a content/viewport geometry revision. */
export function rebaseFrozenScrollbarDrag(
drag: Pick<DragGeometry, "startY" | "startThumbTop" | "overflow" | "maxThumbTop">,
geometry: Pick<DragGeometry, "overflow" | "maxThumbTop">,
scrollTop: number,
clientY: number,
) {
const ratio = geometry.overflow > 0
? Math.max(0, Math.min(1, scrollTop / geometry.overflow))
: 0;
return {
...drag,
startY: clientY,
startThumbTop: Math.round(ratio * geometry.maxThumbTop),
overflow: geometry.overflow,
maxThumbTop: geometry.maxThumbTop,
};
}

/** Creation-mode scrollbar with a pointerdown-frozen drag mapping. */
export function useCreationTranscriptScrollbar({
enabled,
Expand Down Expand Up @@ -90,6 +115,14 @@ export function useCreationTranscriptScrollbar({
return;
}
const drag = dragRef.current;
if (drag && (drag.overflow !== geometry.overflow || drag.maxThumbTop !== geometry.maxThumbTop || drag.thumbHeight !== geometry.thumbHeight)) {
const rebased = rebaseFrozenScrollbarDrag(drag, geometry, element.scrollTop, drag.lastClientY);
drag.startY = rebased.startY;
drag.startThumbTop = rebased.startThumbTop;
drag.overflow = rebased.overflow;
drag.maxThumbTop = rebased.maxThumbTop;
drag.thumbHeight = geometry.thumbHeight;
}
const overflow = drag?.overflow ?? geometry.overflow;
const maxThumbTop = drag?.maxThumbTop ?? geometry.maxThumbTop;
const thumbHeight = drag?.thumbHeight ?? geometry.thumbHeight;
Expand Down Expand Up @@ -130,6 +163,7 @@ export function useCreationTranscriptScrollbar({
const element = scrollRef.current;
if (drag && element && event.pointerId === drag.pointerId) {
const { thumbTop, scrollTop } = mapFrozenScrollbarDrag(drag, event.clientY);
drag.lastClientY = event.clientY;
writeOffset("custom-scrollbar", scrollTop);
setState({ visible: true, hot: true, thumbTop: Math.round(thumbTop), thumbHeight: drag.thumbHeight });
setHot(true);
Expand Down Expand Up @@ -187,7 +221,7 @@ export function useCreationTranscriptScrollbar({
event.preventDefault();
event.stopPropagation();
const startThumbTop = (element.scrollTop / geometry.overflow) * geometry.maxThumbTop;
dragRef.current = { pointerId: event.pointerId, startY: event.clientY, startThumbTop, ...geometry };
dragRef.current = { pointerId: event.pointerId, startY: event.clientY, lastClientY: event.clientY, startThumbTop, ...geometry };
setScrollMode("restoring", "custom-scrollbar-drag");
event.currentTarget.setPointerCapture(event.pointerId);
setHot(true);
Expand All @@ -205,11 +239,26 @@ export function useCreationTranscriptScrollbar({
setState({ visible: true, hot: true, thumbTop: Math.round(thumbTop), thumbHeight: geometry.thumbHeight });
setHot(true);
if (settleFrameRef.current !== null) cancelAnimationFrame(settleFrameRef.current);
settleFrameRef.current = requestAnimationFrame(() => {
let stableFrames = 0;
let previousGeometry = "";
const deadline = Date.now() + RAIL_SETTLE_BUDGET_MS;
const settle = () => {
settleFrameRef.current = null;
const current = scrollRef.current;
syncMetrics();
const geometryKey = current
? `${Math.round(current.scrollHeight)}:${Math.round(current.clientHeight)}:${Math.round(current.scrollTop)}`
: "";
stableFrames = geometryKey !== "" && geometryKey === previousGeometry ? stableFrames + 1 : 0;
previousGeometry = geometryKey;
if (stableFrames < RAIL_SETTLE_STABLE_FRAMES && Date.now() < deadline) {
settleFrameRef.current = requestAnimationFrame(settle);
return;
}
finishProgrammaticScroll();
syncMetrics();
});
};
settleFrameRef.current = requestAnimationFrame(settle);
}, [enabled, finishProgrammaticScroll, scrollRef, setHot, setScrollMode, syncMetrics, writeOffset]);

return { state, handleScroll, onThumbPointerDown, onRailPointerDown };
Expand Down
Loading