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-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__/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
9 changes: 7 additions & 2 deletions desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ console.log("\nbundle budgets");
// bring the merged stable path to 462.587 KiB. Windows' embedded build metadata
// lands just above the rounded 462.6 KiB boundary; retain one cross-platform
// decimal step without widening any chunk or raw gate.
const initialJSBudgetKiB = 462.7;
// 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;
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 @@ -328,6 +331,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// The passive reader-anchor lease for delayed WebView2 range commits measures
// 2469.347 KiB raw (+0.222 KiB, +0.009%). Retain only the next one-decimal
// ceiling; gzip and largest-chunk budgets remain unchanged.
const rawInitialBudgetKiB = 2_469.4;
// 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;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
18 changes: 18 additions & 0 deletions desktop/frontend/scripts/check-motion-ci-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ if (!packageJSON.scripts?.["test:motion-browser"]?.includes("approval-animation.
const transcriptScript = packageJSON.scripts?.["test:transcript"] ?? "";
for (const required of [
"transcript-virtuoso-index.test.ts",
"transcript-reader-visual-guard-race.test.tsx",
"transcript-scroll-release.test.ts",
"nested-scroll-handoff.test.ts",
"creation-transcript-scrollbar.test.ts",
Expand Down Expand Up @@ -175,6 +176,23 @@ for (const required of ["transcript-selection.mjs", "transcript-scroll-stability
}
}

// A near-zero `transition: all` still starts from the old value, so same-frame
// geometry reads miss transform/padding writes and the transcript guards
// compound. The global reduced-motion reset must remove transitions outright.
const stylesSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/styles.css"), "utf8");
const globalReducedMotion = stylesSource.match(
/@media \(prefers-reduced-motion: reduce\) \{\s*\*,\s*\*::before,\s*\*::after \{([^}]*)\}/,
);
if (!globalReducedMotion) {
throw new Error("motion-ci-contract: styles.css must keep the global universal prefers-reduced-motion reset");
}
if (!globalReducedMotion[1].includes("transition: none !important")) {
throw new Error("motion-ci-contract: the global reduced-motion reset must use `transition: none !important`");
}
if (/transition-duration/.test(globalReducedMotion[1])) {
throw new Error("motion-ci-contract: the global reduced-motion reset must not shorten transitions (same-frame geometry reads would lag)");
}

const transcriptCommand = "pnpm --dir frontend test:transcript";
const desktopLinuxJob = jobBody("desktop", "desktop-macos");
const transcriptRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript(?:\s|$)/g)?.length ?? 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
transcriptReaderIdleDeadlineReached,
transcriptReaderTransactionCanReuse,
transcriptReaderExtentCanCorrect,
transcriptTransformTranslateY,
} from "../lib/transcriptReaderExtentStability";

console.log("\ntranscript reader extent stability");
Expand Down Expand Up @@ -163,4 +164,16 @@ for (const event of observingEvents) {
`${event} leaves rebound observation active`);
}

assert.equal(transcriptTransformTranslateY("none"), 0, "an unset transform applies no visual offset");
assert.equal(transcriptTransformTranslateY(""), 0, "an empty computed transform applies no visual offset");
assert.equal(transcriptTransformTranslateY("matrix(1, 0, 0, 1, 0, 7088.5)"), 7088.5, "a 2D matrix exposes its translateY");
assert.equal(transcriptTransformTranslateY("matrix(1, 0, 0, 1, 0, -12)"), -12, "a negative translateY survives parsing");
assert.equal(
transcriptTransformTranslateY("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 96, 0, 1)"),
96,
"a 3D matrix exposes its translateY component",
);
assert.equal(transcriptTransformTranslateY("translateY(12px)"), undefined, "an unserialized transform yields no measurement");
assert.equal(transcriptTransformTranslateY("rotate(45deg)"), undefined, "an unrelated transform yields no measurement");

console.log("transcript reader extent stability tests passed");
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Run: tsx src/__tests__/transcript-reader-visual-guard-race.test.tsx
//
// Visual-guard races split out of transcript-reader-extent-race.test.tsx
// (800-line test-file ceiling). Under prefers-reduced-motion, Windows/WebView2
// lets the guard transform lag behind its same-frame write, and another guard
// owner can drop the shared attribute. The reader guard must derive the
// physical drift from the transform the browser actually applied, never
// compounding 681 → 1362 → 2043. Same JSDOM + fake rAF harness with a stubbed
// VirtuosoHandle as the extent race file.

import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { VirtuosoHandle } from "react-virtuoso";
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";

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 reader visual guard races");

const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></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;

let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
const requestFrame = (callback: FrameRequestCallback) => {
const id = nextFrame;
nextFrame += 1;
frames.set(id, callback);
return id;
};
const cancelFrame = (id: number) => void frames.delete(id);
globalThis.requestAnimationFrame = requestFrame;
globalThis.cancelAnimationFrame = cancelFrame;
dom.window.requestAnimationFrame = requestFrame;
dom.window.cancelAnimationFrame = cancelFrame;

async function flushFrames() {
const pending = [...frames.values()];
frames.clear();
await act(async () => pending.forEach((callback) => callback(performance.now())));
}

const scrollWrites: TranscriptScrollWriteRecord[] = [];
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };

const rectAt = (top: number) => ({
top,
bottom: top + 100,
height: 100,
left: 0,
right: 800,
width: 800,
x: 0,
y: top,
toJSON: () => ({}),
});
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
rowElement.dataset.index = "0";
scrollElement.getBoundingClientRect = () => rectAt(0);
rowElement.getBoundingClientRect = () => rectAt(12);
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
let scrollExtent = 23_806;
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 22_608 });

let scrollByCalls = 0;
let lastScrollByTop = 0;
const virtuosoHandle = {
scrollBy: (options?: { top?: number }) => {
scrollByCalls += 1;
lastScrollByTop = options?.top ?? 0;
scrollElement.scrollTop += lastScrollByTop;
},
scrollTo: (options?: { top?: number }) => {
scrollElement.scrollTop = options?.top ?? scrollElement.scrollTop;
},
scrollToIndex: () => {},
getState: () => {},
} as unknown as VirtuosoHandle;

let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
function Probe() {
arbiter = useTranscriptScrollArbiter();
return null;
}

const root = createRoot(dom.window.document.getElementById("root")!);
await act(async () => root.render(<Probe />));
await act(async () => {
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
arbiter!.scrollerRef(scrollElement);
});

const visualOffsetOf = () => Number.parseFloat(
scrollElement.style.getPropertyValue("--transcript-reader-visual-offset"),
) || 0;
const itemList = dom.window.document.createElement("div");
itemList.dataset.testid = "virtuoso-item-list";
itemList.style.transform = "none";
scrollElement.append(itemList);

// A downward wheel gesture whose same-scrollTop estimate growth displaces the
// anchor row by 681px on screen. The row rect deliberately ignores the guard
// CSS variable: the transform has not been applied by the browser yet.
const startDisplacedTransaction = async () => {
await act(async () => arbiter?.reset());
scrollExtent = 23_806;
scrollElement.scrollTop = 22_608;
rowElement.getBoundingClientRect = () => rectAt(12);
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.releaseTailFollow());
await act(async () => arbiter?.onWheelIntent({
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 24,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
scrollByCalls = 0;
scrollWrites.length = 0;
scrollExtent += 681;
rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608));
};

await startDisplacedTransaction();
await act(async () => arbiter?.deliverScroll());
check(Math.abs(visualOffsetOf() + 681) <= 1,
`an unapplied guard is written once from the physical drift (${visualOffsetOf()}px)`);
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.deliverScroll());
check(Math.abs(visualOffsetOf() + 681) <= 1,
`repeated observations before the transform lands do not compound the guard (${visualOffsetOf()}px)`);
await flushFrames();
check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1,
`the correction targets the physical anchor, not a compounded guard (${lastScrollByTop}px)`);
check(scrollElement.dataset.transcriptReaderVisualGuard === undefined,
"the unapplied guard releases after the anchor is physically restored");
check(
scrollWrites.filter((write) => write.owner === "reader-stability" && write.kind === "scrollBy").length === 1,
"the unapplied-guard correction stays inside the reader writer lane",
);

// The applied transform is the truth even when the remembered offset is gone:
// a mounted item list carrying the guard transform must still be subtracted.
const syncItemListTransform = () => {
const applied = scrollElement.dataset.transcriptReaderVisualGuard === "true" ? visualOffsetOf() : 0;
itemList.style.transform = applied === 0 ? "none" : `matrix(1, 0, 0, 1, 0, ${applied})`;
};
await startDisplacedTransaction();
rowElement.getBoundingClientRect = () => rectAt(693 - (scrollElement.scrollTop - 22_608) + (
Number.parseFloat(itemList.style.transform.split(",")[5]) || 0
));
await act(async () => arbiter?.deliverScroll());
syncItemListTransform();
check(Math.abs(visualOffsetOf() + 681) <= 1,
`an applied guard is written from the physical drift (${visualOffsetOf()}px)`);
await act(async () => arbiter?.deliverScroll());
syncItemListTransform();
check(Math.abs(visualOffsetOf() + 681) <= 1,
`an applied guard stays put across observations (${visualOffsetOf()}px)`);
await flushFrames();
syncItemListTransform();
check(scrollByCalls === 1 && Math.abs(lastScrollByTop - 681) <= 1,
`the correction subtracts the applied transform (${lastScrollByTop}px)`);
await flushFrames();
syncItemListTransform();
check(scrollElement.dataset.transcriptReaderVisualGuard === undefined,
"the applied guard releases once the correction lands");

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

if (failed > 0) {
console.error(`\n${failed} transcript reader visual guard race test(s) failed; ${passed} passed.`);
process.exit(1);
}
console.log(`\n${passed} transcript reader visual guard race tests passed.`);
4 changes: 2 additions & 2 deletions desktop/frontend/src/lib/transcriptAnchorCompensation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
captureVisibleTranscriptLayoutAnchor,
type TranscriptLayoutAnchor,
} from "./transcriptVirtuosoRecovery";
import { MIN_REVERSE_JUMP_PX } from "./transcriptReaderExtentStability";
import { MIN_REVERSE_JUMP_PX, transcriptAppliedVisualOffset } from "./transcriptReaderExtentStability";

// Fractional row metrics can shift by 1-2px while Virtuoso's estimate tree is
// converging during ordinary traversal. Treat that as layout noise so a
Expand Down Expand Up @@ -100,7 +100,7 @@ export function createTranscriptAnchorCompensation({
const row = anchorRow(compensation.anchor.rowKey, element);
if (!row) return null;
const rendered = row.getBoundingClientRect().top - element.getBoundingClientRect().top - compensation.anchor.offset;
return rendered - compensation.visualOffset;
return rendered - transcriptAppliedVisualOffset(element, compensation.visualOffset);
};

const guardLargeDrift = (compensation: ActiveAnchorCompensation, element: HTMLDivElement) => {
Expand Down
Loading
Loading