From 0ad0fcf3a07b7996c28693970b0d76179b38cd7e Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:34:12 +0800 Subject: [PATCH 01/15] fix(frontend): omit empty fenced markdown blocks Problem: empty fenced code blocks rendered as bordered phantom cards in shared Markdown surfaces.\n\nRoot cause: the shared code component always mounted CodeViewer for block-shaped code, including whitespace-only fences.\n\nFix: omit whitespace-only fenced blocks while preserving non-empty code and add renderer parity coverage.\n\nVerification: pnpm exec tsx src/__tests__/markdown-pipeline.test.tsx; pnpm test:transcript; pnpm build. --- .../src/__tests__/markdown-pipeline.test.tsx | 15 +++++++++++++++ .../src/components/markdownComponents.tsx | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/desktop/frontend/src/__tests__/markdown-pipeline.test.tsx b/desktop/frontend/src/__tests__/markdown-pipeline.test.tsx index 6a9a94ffef..97eae9d779 100644 --- a/desktop/frontend/src/__tests__/markdown-pipeline.test.tsx +++ b/desktop/frontend/src/__tests__/markdown-pipeline.test.tsx @@ -111,6 +111,21 @@ for (const [name, text] of Object.entries(fixtures)) { eq(sliced, expected, `${name}: sliced blocks render identically (${blocks.length} blocks)`); } +// Empty fenced blocks are formatting placeholders. They must not create a +// bordered CodeViewer, while comment-only and whitespace-adjacent blocks stay +// visible as real code content. +{ + const emptyFences = "Before\n\n```\n\n```\n\nAfter\n\n```ts\n \n```"; + const html = renderCurrent(emptyFences); + ok(!html.includes("code-block"), "empty fenced blocks do not render phantom code cards"); + ok(html.includes("Before") && html.includes("After"), "text around empty fenced blocks remains visible"); + + const comments = "```ts\n// keep this comment\nconst stable = true;\n```"; + const commentHtml = renderCurrent(comments); + ok(commentHtml.includes("keep this comment"), "non-empty comment code blocks remain visible"); + ok(commentHtml.includes("code-block"), "non-empty comment code keeps its code-block surface"); +} + // Block keys are stable top-level indexes. { const blocks = parseMarkdownToBlocks("one\n\ntwo\n\nthree"); diff --git a/desktop/frontend/src/components/markdownComponents.tsx b/desktop/frontend/src/components/markdownComponents.tsx index f66b3f8f0c..609eb0f0d1 100644 --- a/desktop/frontend/src/components/markdownComponents.tsx +++ b/desktop/frontend/src/components/markdownComponents.tsx @@ -59,6 +59,7 @@ function splitPlainBlock(text: string): { preText: string; statusItems: string[] } function PlainMarkdownBlock({ text }: { text: string }) { + if (text.trim() === "") return null; const { preText, statusItems } = splitPlainBlock(text); const asList = statusItems.length >= 2; return ( @@ -97,6 +98,11 @@ export function createComponents(plainStatusBlocks: boolean): Components { const isBlock = match !== null || text.includes("\n"); if (isBlock) { const value = text.replace(/\n$/, ""); + // Empty fenced blocks are formatting placeholders, not useful + // transcript content. Omitting them prevents a bordered one-line + // CodeViewer from becoming a phantom row/height in every Markdown + // surface while preserving comments and other non-empty code. + if (value.trim() === "") return null; if (lang === "mermaid") { return ( }> From c6500f3580bfb1c921fb604a28442422a907ae06 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:35:59 +0800 Subject: [PATCH 02/15] fix(frontend): fence transcript geometry transactions Problem: history prepends, virtualized measurement, scrollbar drags, and question jumps could restore stale anchors and move the reader by multiple screens.\n\nRoot cause: small sessions were fully mounted during reader gestures, prepend coverage waited on the whole list, scrollbar mapping stayed on stale extents, and diagnostics did not classify restore-anchor reversals.\n\nFix: add tokenized surface transaction telemetry, use bounded reader corridors with logical-anchor coverage, rebase custom scrollbar drags after geometry changes, add a stable 16px question-jump offset correction, and report unauthorized scroll reversals.\n\nVerification: pnpm test:transcript; PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm test:transcript-browser; PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm test:transcript-reader-browser; pnpm test:typecheck; pnpm build. --- desktop/frontend/package.json | 2 +- .../frontend/scripts/check-bundle-budget.mjs | 17 ++--- .../creation-transcript-scrollbar.test.ts | 11 ++- .../__tests__/frontend-diagnostics.test.ts | 6 ++ .../transcript-question-jump.test.tsx | 4 ++ .../transcript-surface-transaction.test.ts | 32 +++++++++ .../frontend/src/components/Transcript.tsx | 12 ++-- .../frontend/src/lib/frontendDiagnostics.ts | 20 +++++- .../src/lib/transcriptHistoryPrependLease.ts | 67 ++++++++++++++++--- .../src/lib/transcriptSurfaceTransaction.ts | 65 ++++++++++++++++++ .../src/lib/useCreationTranscriptScrollbar.ts | 49 +++++++++++++- .../src/lib/useTranscriptGeometryLifecycle.ts | 2 +- .../lib/useTranscriptQuestionNavigation.ts | 34 +++++++++- .../lib/useTranscriptReaderExtentStability.ts | 17 ++++- .../src/lib/useTranscriptScrollArbiter.ts | 12 ++++ 15 files changed, 312 insertions(+), 38 deletions(-) create mode 100644 desktop/frontend/src/__tests__/transcript-surface-transaction.test.ts create mode 100644 desktop/frontend/src/lib/transcriptSurfaceTransaction.ts diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 8df3976675..68c33f679c 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -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-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__/transcript-surface-transaction.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", diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 0f1a6384a2..2d7a38afe9 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -180,9 +180,11 @@ console.log("\nbundle budgets"); // move the combined path to 462.2 KiB. Local spectator reclaim adds the // desktop-vs-remote command branch. Sticky Context's session-scoped file chips // 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; +// lands just above the rounded 462.6 KiB boundary. The generation-bound +// surface transaction and content-free reversal diagnostics add 0.7 KiB to +// the initial path; retain the smallest one-decimal ratchet without widening +// any chunk or raw gate. +const initialJSBudgetKiB = 463.5; 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 @@ -324,10 +326,9 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // path 2465.105 KiB raw; the merged test channel measures 2464.979 KiB. // Session takeover banners and #9703/#9711's provisional-selection handoff // combine with Sticky Context's pinned-file state at 2469.125 KiB raw on the -// merged stable path. Retain only the next one-decimal ceiling. -// 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; +// merged stable path. The passive reader-anchor lease for delayed WebView2 +// range commits remains covered by the bounded surface transaction, +// scrollbar rebase, and Markdown empty-block guard budget of 2472.2 KiB raw. +const rawInitialBudgetKiB = 2_472.4; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/frontend/src/__tests__/creation-transcript-scrollbar.test.ts b/desktop/frontend/src/__tests__/creation-transcript-scrollbar.test.ts index 865af36256..6134177f4f 100644 --- a/desktop/frontend/src/__tests__/creation-transcript-scrollbar.test.ts +++ b/desktop/frontend/src/__tests__/creation-transcript-scrollbar.test.ts @@ -2,6 +2,7 @@ import { mapFrozenScrollbarDrag, + rebaseFrozenScrollbarDrag, readCreationScrollbarGeometry, } from "../lib/useCreationTranscriptScrollbar"; @@ -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"); diff --git a/desktop/frontend/src/__tests__/frontend-diagnostics.test.ts b/desktop/frontend/src/__tests__/frontend-diagnostics.test.ts index 09f02d2abb..12ee4f42c0 100644 --- a/desktop/frontend/src/__tests__/frontend-diagnostics.test.ts +++ b/desktop/frontend/src/__tests__/frontend-diagnostics.test.ts @@ -154,5 +154,11 @@ assert.deepEqual(analyzeFrontendDiagnosticAnomalies([ { t: 0, type: "navigation.begin", intent: 9 }, { t: 1, type: "navigation.settle", intent: 9, outcome: "failed" }, ]), [], "a failed data terminal may release its mask without a paint-ready false positive"); +assert.deepEqual(analyzeFrontendDiagnosticAnomalies([ + { t: 0, type: "transcript.scroll-anomaly", transactionId: 40, result: "restore-anchor", reverseDisplacement: 2.01 }, + { t: 1, type: "transcript.scroll-anomaly", transactionId: 40, result: "restore-anchor", reverseDisplacement: 27_104.41 }, +]), [ + { code: "unauthorized-scroll-reversal", transactionId: 40, maxReverseDisplacement: 27_104.41 }, +], "large reader reversals are surfaced in the diagnostic summary"); console.log("frontend diagnostics tests passed"); diff --git a/desktop/frontend/src/__tests__/transcript-question-jump.test.tsx b/desktop/frontend/src/__tests__/transcript-question-jump.test.tsx index 1ae35f5fee..b236d6e1b0 100644 --- a/desktop/frontend/src/__tests__/transcript-question-jump.test.tsx +++ b/desktop/frontend/src/__tests__/transcript-question-jump.test.tsx @@ -139,6 +139,10 @@ ok(newestSurface === null, "only C's own terminal releases the latest surface"); rowTop >= el.scrollTop - 100 && rowTop <= el.scrollTop + el.clientHeight, `jump to question ${targetIndex + 1} lands its row inside the viewport (rowTop ${rowTop}, scrollTop ${el.scrollTop})`, ); + if (targetIndex > 0 && targetIndex < 39 && rowTop !== null) { + ok(Math.abs(rowTop - el.scrollTop - 16) <= 1, + `jump to question ${targetIndex + 1} keeps the selected row near the 16px top margin`); + } const expectedText = `question ${targetIndex}`; ok(anchor.textContent?.includes(expectedText) ?? false, `jump to question ${targetIndex + 1} mounts the selected question content`); } diff --git a/desktop/frontend/src/__tests__/transcript-surface-transaction.test.ts b/desktop/frontend/src/__tests__/transcript-surface-transaction.test.ts new file mode 100644 index 0000000000..fabed4e25c --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-surface-transaction.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { createTranscriptSurfaceTransactions } from "../lib/transcriptSurfaceTransaction"; + +const events: Array<{ type: string; fields: Record }> = []; +const previous = (globalThis as { window?: Window }).window; +const windowStub = { + __REASONIX_TRANSCRIPT_SCROLL_DIAGNOSTIC__: (type: string, fields: Record) => events.push({ type, fields }), +} as unknown as Window; +(globalThis as { window?: Window }).window = windowStub; + +const transactions = createTranscriptSurfaceTransactions(); +const first = transactions.begin({ + kind: "reader-prepend", + surfaceGeneration: 3, + ownershipEpoch: 8, + geometryRevision: 13, + mutationSeq: 21, + anchor: { rowKey: "row-a", logicalIndex: 44, viewportOffset: 16 }, +}); +assert.equal(first.token, 1); +assert.equal(transactions.isCurrent(first.token), true); +assert.equal(transactions.update(first.token, { phase: "mutating", mutationSeq: 22 }, "prepend"), true); +assert.equal(transactions.update(first.token - 1, { phase: "settling" }), false); +assert.equal(transactions.finish(first.token - 1, "committed"), false); +assert.equal(transactions.finish(first.token, "committed"), true); +assert.equal(transactions.current(), null); +assert.deepEqual(events.map((event) => event.fields.result), ["begin", "prepend", "committed"]); +assert.equal(events[0]?.fields.anchorIndex, 44); +assert.equal(events[0]?.fields.anchorOffset, 16); + +(globalThis as { window?: Window }).window = previous; +console.log("transcript surface transaction tests passed"); diff --git a/desktop/frontend/src/components/Transcript.tsx b/desktop/frontend/src/components/Transcript.tsx index 7be540a9e0..cef9aa343c 100644 --- a/desktop/frontend/src/components/Transcript.tsx +++ b/desktop/frontend/src/components/Transcript.tsx @@ -55,7 +55,6 @@ import { useTranscriptSelectableRows } from "../lib/useTranscriptSelectableRows" import { useCreationTranscriptScrollbar } from "../lib/useCreationTranscriptScrollbar"; import { useTranscriptScrollInteractions } from "../lib/useTranscriptScrollInteractions"; import { hasTranscriptScrollableRange, TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX, useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter"; -import { TRANSCRIPT_READER_FULL_MOUNT_ROW_LIMIT } from "../lib/transcriptHistoryPrependLease"; import { useTranscriptLayoutIntegrity } from "../lib/useTranscriptLayoutIntegrity"; import { TranscriptLayoutIntentProvider, TranscriptScrollWriteProvider } from "./TranscriptLayoutIntentContext"; import { MarkdownImageTabContext } from "./MarkdownImageContext"; @@ -89,7 +88,7 @@ const FrontendDiagnosticsPanel = SHOW_FRONTEND_DIAGNOSTICS ? lazy(() => import("./FrontendDiagnosticsPanel")) : null; const VIRTUAL_OVERSCAN_ROWS = 8; -const READER_MOUNT_CORRIDOR_ROWS = 112; +const READER_MOUNT_CORRIDOR_ROWS = 256; const READER_MOUNT_CORRIDOR_VIEWPORTS = 7; // Keep paged history measured during manual reading so WKWebView cannot replace // non-overlapping ranges without an anchor; large sessions keep a bounded corridor. @@ -606,7 +605,7 @@ export function Transcript({ const [handleJumpToQuestion, handleEarlierHistoryReached, retryOlderHistory, questionJumpSurface] = useTranscriptQuestionJump({ questions, loadedByTurn, layoutSurfaceKey, rowIndexByKey, hasOlderHistory, loadingOlderHistory, olderHistoryError, running, scrollElement, scheduleRecovery: scheduleBlankViewportCheck, - onLoadOlderHistory, clearTranscriptSelection, invalidateAnchors, + onLoadOlderHistory, clearTranscriptSelection, invalidateAnchors, writeOffset, beginQuestionJump, finishQuestionJump, scrollToDataIndex, setActiveQuestion, rewindSignal, }); const handleViewportEarlierHistoryReached = useCallback(() => { @@ -853,9 +852,10 @@ export function Transcript({ }, [contentRevision, holdingLiveRegion, scrollRef, virtualRows.length]); const heldLiveRows = heldSurfaceRef.current === layoutSurfaceKey ? heldLiveRowsRef.current : NO_HELD_ROWS; const showLiveRegion = liveSplit.liveActive || (holdingLiveRegion && heldLiveRows.length > 0); - const readerMountCorridorRows = readerTransactionActive && virtualRows.length <= TRANSCRIPT_READER_FULL_MOUNT_ROW_LIMIT - ? Math.max(READER_MOUNT_CORRIDOR_ROWS, virtualRows.length) - : READER_MOUNT_CORRIDOR_ROWS; + // Keep a bounded corridor for every reader transaction. Full-mounting small + // sessions makes every Markdown row measure during one wheel gesture and can + // recreate the extent-collapse/anchor jump seen in field diagnostics. + const readerMountCorridorRows = READER_MOUNT_CORRIDOR_ROWS; const { handleItemsRendered, handleTotalListHeightChanged } = useTranscriptGeometryLifecycle({ virtualRowCount: virtualRows.length, hydrating, readerTransactionActive, historyMutation, historyPrependLease, scrollModeRef, followGrowingTail, revalidateTail, diff --git a/desktop/frontend/src/lib/frontendDiagnostics.ts b/desktop/frontend/src/lib/frontendDiagnostics.ts index a41c185cb6..ada315b74f 100644 --- a/desktop/frontend/src/lib/frontendDiagnostics.ts +++ b/desktop/frontend/src/lib/frontendDiagnostics.ts @@ -84,6 +84,10 @@ export type FrontendDiagnosticEvent = { ownershipEpoch?: number; geometryRevision?: number; transactionId?: number; + transactionKind?: string; + targetTurn?: number; + anchorIndex?: number; + anchorOffset?: number; footerHeight?: number; viewport?: number; mounted?: number; @@ -159,9 +163,11 @@ export type FrontendDiagnosticEvent = { }; export type FrontendDiagnosticAnomaly = { - code: "settle-before-paint-ready" | "viewport-older-without-user-input" | "navigation-session-count-changed" | "unknown-scroll-writer" | "target-empty-sequence"; + code: "settle-before-paint-ready" | "viewport-older-without-user-input" | "navigation-session-count-changed" | "unknown-scroll-writer" | "target-empty-sequence" | "unauthorized-scroll-reversal"; intent?: number; count?: number; + transactionId?: number; + maxReverseDisplacement?: number; }; export type FrontendDiagnosticEnvironment = { @@ -207,7 +213,8 @@ const NUMBER_FIELDS = [ "width", "height", "x", "y", "deltaX", "deltaY", "targetTop", "listHeight", "durationMs", "scrollTop", "scrollHeight", "clientHeight", "bottomDistance", "mountedRows", "totalRows", "firstVisibleIndex", "firstVisibleTop", "rowIndex", "estimatedSize", "previousSize", "measuredSize", "sizeDelta", "relativeError", "disclosureCount", "contentRevision", "tabCount", "patchCount", "button", "modifiers", "intent", - "sequence", "generation", "surfaceGeneration", "ownershipEpoch", "geometryRevision", "transactionId", "footerHeight", "viewport", "mounted", "total", "reverseDisplacement", "extentDelta", "stableFrames", "direction", + "sequence", "generation", "surfaceGeneration", "ownershipEpoch", "geometryRevision", "transactionId", "targetTurn", "footerHeight", "viewport", "mounted", "total", "reverseDisplacement", "extentDelta", "stableFrames", "direction", + "anchorIndex", "anchorOffset", "workspaceSessions", "visibleSessions", "hiddenSessions", "hiddenByFilter", "hiddenByCollapsed", "hiddenByTruncation", "runtimeSessions", "runtimeOnlySessions", "recoveryOnlySessions", "recoveryCopySessions", "recoveryCopies", "runningSessions", "unreadSessions", "pinnedSessions", "activeSessions", "activeVisibleSessions", "folderCount", "expandedFolders", "showAllFolders", "catalogRevision", "catalogIndexed", "catalogTotal", "repairPending", "treeRevision", "organizationRevision", "unloadedSessions", "deltaWorkspaceSessions", "deltaVisibleSessions", "deltaHiddenSessions", "deltaRecoveryCopies", "deltaRuntimeOnlySessions", ] as const; const BOOLEAN_FIELDS = [ @@ -218,6 +225,7 @@ const STRING_FIELDS = [ "source", "eventSource", "action", "target", "targetRole", "targetTag", "keyClass", "pointerType", "inputType", "visibility", "phase", "reason", "rejectedReason", "result", "status", "mode", "previousMode", "owner", "writeKind", "rowKind", "layoutVersion", "layoutVariant", "estimateSource", "foldState", "state", "errorName", "errorCode", "directoryState", "changeReason", "outcome", "trigger", "scope", "variant", "timeFilter", + "transactionKind", ] as const; const GEOMETRY_SOURCES = new Set([ "footer-resize", "row-measure", "data-change", "viewport-resize", "fold-change", "typography-change", "items-rendered", @@ -234,6 +242,7 @@ export function analyzeFrontendDiagnosticAnomalies(events: readonly FrontendDiag let viewportPermit = 0; let unknownWriters = 0; let unpermittedOlder = 0; + const scrollReversals = new Map(); for (const event of events) { if (event.type === "history.viewport-permit") viewportPermit = 1; if (event.type === "navigation.begin" && event.intent !== undefined) { @@ -249,6 +258,10 @@ export function analyzeFrontendDiagnosticAnomalies(events: readonly FrontendDiag else unpermittedOlder += 1; } if (event.type === "transcript.scroll-write" && event.owner && !knownScrollWriters.has(event.owner)) unknownWriters += 1; + if (event.type === "transcript.scroll-anomaly" && event.result === "restore-anchor" && (event.reverseDisplacement ?? 0) > 2) { + const transactionId = event.transactionId ?? -1; + scrollReversals.set(transactionId, Math.max(scrollReversals.get(transactionId) ?? 0, event.reverseDisplacement ?? 0)); + } for (const [intent, state] of navigation) { if (painted.has(intent)) continue; if (event.workspaceSessions !== undefined) state.counts.add(event.workspaceSessions); @@ -273,6 +286,9 @@ export function analyzeFrontendDiagnosticAnomalies(events: readonly FrontendDiag } if (unpermittedOlder > 0) anomalies.push({ code: "viewport-older-without-user-input", count: unpermittedOlder }); if (unknownWriters > 0) anomalies.push({ code: "unknown-scroll-writer", count: unknownWriters }); + for (const [transactionId, maxReverseDisplacement] of scrollReversals) { + anomalies.push({ code: "unauthorized-scroll-reversal", transactionId: transactionId >= 0 ? transactionId : undefined, maxReverseDisplacement }); + } return anomalies; } diff --git a/desktop/frontend/src/lib/transcriptHistoryPrependLease.ts b/desktop/frontend/src/lib/transcriptHistoryPrependLease.ts index 5871970e0e..908eb20ef6 100644 --- a/desktop/frontend/src/lib/transcriptHistoryPrependLease.ts +++ b/desktop/frontend/src/lib/transcriptHistoryPrependLease.ts @@ -1,6 +1,5 @@ import type { RefObject } from "react"; - -export const TRANSCRIPT_READER_FULL_MOUNT_ROW_LIMIT = 1_000; +import { createTranscriptSurfaceTransactions, type TranscriptSurfaceTransaction } from "./transcriptSurfaceTransaction"; export type TranscriptHistoryPrependLease = { pendingRef: RefObject; @@ -8,7 +7,7 @@ export type TranscriptHistoryPrependLease = { requestRef: RefObject; mutationBaselineRef: RefObject; begin: (mutationSeq: number) => number; - noteMutation: (generation: number) => void; + noteMutation: (generation: number, mutationSeq?: number) => void; noteCoverage: (generation: number, mounted: number, total: number) => void; cancel: (generation: number) => boolean; }; @@ -20,6 +19,8 @@ type TranscriptHistoryPrependRuntime = { readerAnchorIsMounted: () => boolean; readerTransactionIsActive: () => boolean; commitGeometry: () => void; + transactionContext?: () => Pick; + captureAnchor?: () => TranscriptSurfaceTransaction["anchor"]; }; export type TranscriptHistoryPrependCoordinator = { @@ -31,6 +32,7 @@ export type TranscriptHistoryPrependCoordinator = { noteGeometryCommitReady: () => void; noteReaderTerminal: (cancelled: boolean) => void; invalidate: () => void; + currentTransaction: () => TranscriptSurfaceTransaction | null; }; /** Owns one or more contiguous history pages without becoming a scroll writer. */ @@ -43,6 +45,8 @@ export function createTranscriptHistoryPrependCoordinator(): TranscriptHistoryPr const stableAnchorRef = { current: false }; const coverageReadyRef = { current: false }; let runtime: TranscriptHistoryPrependRuntime | undefined; + const surfaceTransactions = createTranscriptSurfaceTransactions(); + let surfaceTransaction: TranscriptSurfaceTransaction | null = null; const clear = (preserveStableAnchor = false) => { pendingRef.current = false; @@ -59,6 +63,11 @@ export function createTranscriptHistoryPrependCoordinator(): TranscriptHistoryPr if (!coverageReadyRef.current || (runtime?.readerTransactionIsActive() && !commitReadyRef.current)) return false; const preserveStableAnchor = Boolean(runtime?.readerTransactionIsActive()); stableAnchorRef.current = preserveStableAnchor; + if (surfaceTransaction) { + surfaceTransactions.update(surfaceTransaction.token, { phase: "settling" }, "coverage-ready"); + surfaceTransactions.finish(surfaceTransaction.token, "committed"); + surfaceTransaction = null; + } clear(preserveStableAnchor); runtime?.commitGeometry(); return true; @@ -71,6 +80,20 @@ export function createTranscriptHistoryPrependCoordinator(): TranscriptHistoryPr pendingRef.current = true; commitReadyRef.current = false; coverageReadyRef.current = false; + if (!continuing) { + surfaceTransaction = surfaceTransactions.begin({ + kind: "reader-prepend", + ...(runtime?.transactionContext?.() ?? { + surfaceGeneration: generationRef.current, + ownershipEpoch: 0, + geometryRevision: 0, + }), + mutationSeq, + anchor: runtime?.captureAnchor?.(), + }); + } else if (surfaceTransaction) { + surfaceTransactions.update(surfaceTransaction.token, { phase: "mutating", mutationSeq }, "next-page"); + } if (runtime) { runtime.layoutTransientRef.current = true; runtime.publishPending(true); @@ -78,23 +101,34 @@ export function createTranscriptHistoryPrependCoordinator(): TranscriptHistoryPr } return generationRef.current; }; - const noteMutation = (generation: number) => { + const noteMutation = (generation: number, mutationSeq = mutationBaselineRef.current) => { if (!pendingRef.current || generationRef.current !== generation) return; commitReadyRef.current = false; coverageReadyRef.current = false; + if (surfaceTransaction) { + surfaceTransactions.update(surfaceTransaction.token, { + phase: "mutating", + mutationSeq, + anchor: runtime?.captureAnchor?.() ?? surfaceTransaction.anchor, + }, "mutation"); + } runtime?.holdReaderGeometryCommit(false); }; - const noteCoverage = (generation: number, mounted: number, total: number) => { + const noteCoverage = (generation: number, mounted: number, _total: number) => { if (!pendingRef.current || generationRef.current !== generation) return; - coverageReadyRef.current = mounted >= total || ( - total > TRANSCRIPT_READER_FULL_MOUNT_ROW_LIMIT - && mounted > 0 - && Boolean(runtime?.readerAnchorIsMounted()) - ); + // Never require the whole list to mount. A bounded reader corridor is + // sufficient as long as the logical anchor is mounted; full-list mounts + // make every Markdown row measure at once and recreate the field jump. + coverageReadyRef.current = mounted > 0 && Boolean(runtime?.readerAnchorIsMounted()); + if (surfaceTransaction) surfaceTransactions.update(surfaceTransaction.token, { phase: "mounting" }, "coverage"); finish(generation); }; const cancel = (generation: number) => { if (!pendingRef.current || generationRef.current !== generation) return false; + if (surfaceTransaction) { + surfaceTransactions.finish(surfaceTransaction.token, "cancelled", "cancel"); + surfaceTransaction = null; + } clear(); return true; }; @@ -118,12 +152,23 @@ export function createTranscriptHistoryPrependCoordinator(): TranscriptHistoryPr }, noteReaderTerminal: (cancelled) => { if (!pendingRef.current) return; - if (cancelled) clear(); + if (cancelled) { + if (surfaceTransaction) { + surfaceTransactions.finish(surfaceTransaction.token, "cancelled", "reader-cancelled"); + surfaceTransaction = null; + } + clear(); + } else finish(generationRef.current); }, invalidate: () => { generationRef.current += 1; + if (surfaceTransaction) { + surfaceTransactions.finish(surfaceTransaction.token, "cancelled", "invalidate"); + surfaceTransaction = null; + } clear(); }, + currentTransaction: () => surfaceTransaction, }; } diff --git a/desktop/frontend/src/lib/transcriptSurfaceTransaction.ts b/desktop/frontend/src/lib/transcriptSurfaceTransaction.ts new file mode 100644 index 0000000000..1d7269cb21 --- /dev/null +++ b/desktop/frontend/src/lib/transcriptSurfaceTransaction.ts @@ -0,0 +1,65 @@ +import { recordTranscriptScrollDiagnostic } from "./transcriptScrollProbe"; + +export type TranscriptSurfaceTransactionKind = "reader-prepend" | "question-jump" | "scrollbar-drag"; +export type TranscriptSurfaceTransactionPhase = "loading" | "mutating" | "mounting" | "settling" | "committed" | "cancelled"; + +export type TranscriptSurfaceTransaction = { + token: number; + kind: TranscriptSurfaceTransactionKind; + phase: TranscriptSurfaceTransactionPhase; + surfaceGeneration: number; + ownershipEpoch: number; + geometryRevision: number; + mutationSeq: number; + anchor?: { rowKey: string; logicalIndex: number; viewportOffset: number }; +}; + +/** + * Shared token/phase bookkeeping for asynchronous transcript owners. The + * controller does not write scrollTop; it only fences stale completions and + * emits content-free evidence for field replays. + */ +export function createTranscriptSurfaceTransactions() { + let nextToken = 0; + let current: TranscriptSurfaceTransaction | null = null; + + const publish = (transaction: TranscriptSurfaceTransaction, result?: string) => { + recordTranscriptScrollDiagnostic("surface-transaction", { + transactionId: transaction.token, + source: transaction.kind, + transactionKind: transaction.kind, + phase: transaction.phase, + result, + generation: transaction.surfaceGeneration, + ownershipEpoch: transaction.ownershipEpoch, + geometryRevision: transaction.geometryRevision, + sequence: transaction.mutationSeq, + anchorIndex: transaction.anchor?.logicalIndex, + anchorOffset: transaction.anchor?.viewportOffset, + }); + }; + + return { + begin(input: Omit): TranscriptSurfaceTransaction { + const transaction: TranscriptSurfaceTransaction = { ...input, token: ++nextToken, phase: "loading" }; + current = transaction; + publish(transaction, "begin"); + return transaction; + }, + update(token: number, patch: Partial>, result = "update"): boolean { + if (!current || current.token !== token) return false; + current = { ...current, ...patch }; + publish(current, result); + return true; + }, + isCurrent(token: number): boolean { return current?.token === token; }, + finish(token: number, phase: "committed" | "cancelled", result: string = phase): boolean { + if (!current || current.token !== token) return false; + current = { ...current, phase }; + publish(current, result); + current = null; + return true; + }, + current: () => current, + }; +} diff --git a/desktop/frontend/src/lib/useCreationTranscriptScrollbar.ts b/desktop/frontend/src/lib/useCreationTranscriptScrollbar.ts index 94c14a4bea..52bbedd1f1 100644 --- a/desktop/frontend/src/lib/useCreationTranscriptScrollbar.ts +++ b/desktop/frontend/src/lib/useCreationTranscriptScrollbar.ts @@ -23,6 +23,7 @@ type ScrollbarState = { type DragGeometry = { pointerId: number; startY: number; + lastClientY: number; startThumbTop: number; overflow: number; maxThumbTop: number; @@ -52,6 +53,25 @@ export function mapFrozenScrollbarDrag( return { thumbTop, scrollTop }; } +/** Rebase an active drag after a content/viewport geometry revision. */ +export function rebaseFrozenScrollbarDrag( + drag: Pick, + geometry: Pick, + 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, @@ -90,6 +110,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; @@ -130,6 +158,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); @@ -187,7 +216,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); @@ -205,11 +234,25 @@ 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 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 < 2) { + settleFrameRef.current = requestAnimationFrame(settle); + return; + } finishProgrammaticScroll(); syncMetrics(); - }); + }; + settleFrameRef.current = requestAnimationFrame(settle); }, [enabled, finishProgrammaticScroll, scrollRef, setHot, setScrollMode, syncMetrics, writeOffset]); return { state, handleScroll, onThumbPointerDown, onRailPointerDown }; diff --git a/desktop/frontend/src/lib/useTranscriptGeometryLifecycle.ts b/desktop/frontend/src/lib/useTranscriptGeometryLifecycle.ts index 8d0b9d78e5..f1545f5222 100644 --- a/desktop/frontend/src/lib/useTranscriptGeometryLifecycle.ts +++ b/desktop/frontend/src/lib/useTranscriptGeometryLifecycle.ts @@ -61,7 +61,7 @@ export function useTranscriptGeometryLifecycle({ if (ownsRequest && existing.targetRowCount === virtualRowCount && existing.mutationSeq === historyMutation.seq) return existing; - historyPrependLease.noteMutation(generation); + historyPrependLease.noteMutation(generation, historyMutation.seq); const active = { generation, request, targetRowCount: virtualRowCount, mutationSeq: historyMutation.seq }; activePrependRef.current = active; return active; diff --git a/desktop/frontend/src/lib/useTranscriptQuestionNavigation.ts b/desktop/frontend/src/lib/useTranscriptQuestionNavigation.ts index d5b9612259..7c13fddc16 100644 --- a/desktop/frontend/src/lib/useTranscriptQuestionNavigation.ts +++ b/desktop/frontend/src/lib/useTranscriptQuestionNavigation.ts @@ -13,6 +13,7 @@ import { type QuestionAnchorPosition, } from "./transcriptGrouping"; import { userRowKey } from "./transcriptRows"; +import { recordTranscriptScrollDiagnostic } from "./transcriptScrollProbe"; type PendingQuestionJump = { surfaceKey: string; @@ -135,6 +136,7 @@ export function useTranscriptQuestionJump({ beginQuestionJump, finishQuestionJump, scrollToDataIndex, + writeOffset, setActiveQuestion, rewindSignal, }: { @@ -154,6 +156,7 @@ export function useTranscriptQuestionJump({ beginQuestionJump: (token: number) => void; finishQuestionJump: (token: number) => boolean; scrollToDataIndex: (index: number, behavior?: "auto" | "smooth") => void; + writeOffset: (owner: "anchor-compensation", top: number, behavior?: ScrollBehavior) => boolean; setActiveQuestion: (turn: number | null) => void; rewindSignal: number; }) { @@ -172,6 +175,14 @@ export function useTranscriptQuestionJump({ pendingQuestionRef.current = next; setPendingQuestion((value) => settleQuestionJumpSurfaceState(value, token, next)); finishQuestionJump(token); + recordTranscriptScrollDiagnostic("surface-transaction", { + transactionId: token, + source: "question-jump", + transactionKind: "question-jump", + phase: outcome === "failed" ? "cancelled" : "committed", + result: outcome, + targetTurn: current.turn, + }); recordFrontendDiagnostic("transcript", "transcript.question-jump-terminal", { intent: token, outcome }); }, [finishQuestionJump]); const requestOlderHistory = useCallback(async (targetTurn?: number, retry = false, trigger: HistoryLoadTrigger = "retry"): Promise => { @@ -231,6 +242,14 @@ export function useTranscriptQuestionJump({ // detail of one surface transaction. flushSync(() => replacePendingQuestion(pending)); recordFrontendDiagnostic("transcript", "transcript.question-jump-begin", { intent: pending.token }); + recordTranscriptScrollDiagnostic("surface-transaction", { + transactionId: pending.token, + source: "question-jump", + transactionKind: "question-jump", + phase: pending.phase === "loading" ? "loading" : "mounting", + result: "begin", + targetTurn: pending.turn, + }); beginQuestionJump(pending.token); if (loaded) jumpToLoadedQuestion(question, "auto"); else requestQuestionHistory(pending, true, "question-jump"); @@ -262,6 +281,7 @@ export function useTranscriptQuestionJump({ const { anchorId, token } = pendingQuestion; let frame: number | null = null; let cancelled = false; + let landingCorrectionWritten = false; let progress: SurfacePaintProgress = { attempts: 0, stableFrames: 0 }; const tick = () => { frame = null; @@ -287,6 +307,18 @@ export function useTranscriptQuestionJump({ }); progress = decision.progress; if (decision.outcome) { + if (decision.outcome === "ready" && !landingCorrectionWritten && scrollElement && target) { + const scrollerRect = scrollElement.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const top = Math.max(0, Math.min( + Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight), + scrollElement.scrollTop + targetRect.top - scrollerRect.top - 16, + )); + // The indexed jump remains the sole jump writer. The small final + // top-margin correction is a steady offset adjustment so it cannot + // reopen or duplicate the masked jump transaction. + landingCorrectionWritten = writeOffset("anchor-compensation", top, "auto"); + } settlePendingQuestion(token, decision.outcome); return; } @@ -298,7 +330,7 @@ export function useTranscriptQuestionJump({ cancelled = true; if (frame !== null) cancelAnimationFrame(frame); }; - }, [layoutSurfaceKey, loadingOlderHistory, pendingQuestion, scheduleRecovery, scrollElement, settlePendingQuestion]); + }, [layoutSurfaceKey, loadingOlderHistory, pendingQuestion, scheduleRecovery, scrollElement, settlePendingQuestion, writeOffset]); useEffect(() => { const stale = pendingQuestionRef.current; diff --git a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts index 715b37ccab..9a31ac0fa4 100644 --- a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts +++ b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts @@ -195,6 +195,9 @@ export function useTranscriptReaderExtentStability({ const transaction = transactionRef.current; return !transaction || Boolean(rowForAnchor(transaction.element, transaction.anchor, true)); }, []); + const currentAnchor = useCallback((): TranscriptReaderTransaction["anchor"] => ( + transactionRef.current?.anchor + ), []); const observe = useCallback((element = scrollRef.current) => { const transaction = transactionRef.current; @@ -227,6 +230,10 @@ export function useTranscriptReaderExtentStability({ const renderedAnchorDrift = anchorRow && transaction.anchor ? anchorRow.getBoundingClientRect().top - viewport.top - transaction.anchor.offset : 0; + const anchorOutsideViewport = Boolean(anchorRow && ( + anchorRow.getBoundingClientRect().bottom <= viewport.top + || anchorRow.getBoundingClientRect().top >= viewport.top + element.clientHeight + )); // The row rect includes the current visual guard. Remove it so a stable // guard does not look like a fresh displacement on every observation. const physicalAnchorDrift = renderedAnchorDrift - transaction.visualOffset; @@ -238,7 +245,12 @@ export function useTranscriptReaderExtentStability({ Math.abs(element.scrollHeight - transaction.lastHeight) > GEOMETRY_EPSILON_PX || anchorDisplaced ) geometryCommitReadyRef.current = false; if (anchorRow) transaction.anchorDisplacementObserved = anchorDisplaced; - const rejected = (extentCollapsed && reverse >= threshold) || anchorDisplaced; + // A bounded Virtuoso range can collapse the native extent while the + // logical anchor is temporarily outside the mounted viewport. Treat that + // as the same transient geometry fault as a physical rebound; otherwise + // the guard is cleared for a downward gesture and the next paint exposes + // an empty range (the field #9711 failure shape). + const rejected = (extentCollapsed && (reverse >= threshold || anchorOutsideViewport)) || anchorDisplaced; const remainsCollapsed = extentCollapsed && element.scrollHeight < transaction.baselineHeight - Math.max(8, element.clientHeight * 0.5); if (remainsCollapsed) { @@ -702,7 +714,8 @@ export function useTranscriptReaderExtentStability({ observe, holdGeometryCommit, anchorIsMounted, + currentAnchor, isActive, active: active || readerLayoutLease, - }), [active, anchorIsMounted, arm, cancel, holdGeometryCommit, readerLayoutLease, observe, isActive]); + }), [active, anchorIsMounted, arm, cancel, currentAnchor, holdGeometryCommit, readerLayoutLease, observe, isActive]); } diff --git a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts index 3fc657f2a8..799dfb3dec 100644 --- a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts +++ b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts @@ -118,6 +118,7 @@ export function useTranscriptScrollArbiter({ observe: observeReaderTransaction, holdGeometryCommit: holdReaderGeometryCommit, anchorIsMounted: readerAnchorIsMounted, + currentAnchor: readerCurrentAnchor, isActive: readerTransactionIsActive, active: readerTransactionActive, } = useTranscriptReaderExtentStability({ @@ -172,6 +173,17 @@ export function useTranscriptScrollArbiter({ publishPending: (pending) => { if (scrollRef.current) scrollRef.current.dataset.transcriptHistoryPrependPending = String(pending); }, holdReaderGeometryCommit, readerAnchorIsMounted, readerTransactionIsActive, commitGeometry: () => geometryController.note("items-rendered"), + transactionContext: () => ({ + surfaceGeneration: generationRef.current, + ownershipEpoch: ownershipEpochRef.current, + geometryRevision: geometryRevisionRef.current, + }), + captureAnchor: () => { + const anchor = readerCurrentAnchor(); + return anchor?.key + ? { rowKey: anchor.key, logicalIndex: anchor.index, viewportOffset: anchor.offset } + : undefined; + }, }); const historyPrependLease = historyPrependCoordinator.lease; From 1da366ac3c5e4cb2b6a8479e2f9182f2cd1ffaab Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:41:46 +0800 Subject: [PATCH 03/15] fix(frontend): fence native transcript scrollbar generations Problem: a native thumb completion could outlive the transcript surface that started it.\n\nRoot cause: native scrollbar ownership tracked pointer identity but not the surface generation.\n\nFix: bind native thumb transactions to generationRef and reject stale observations, finishes, and activity checks.\n\nVerification: pnpm exec tsx src/__tests__/transcript-reader-extent-race.test.tsx; pnpm exec tsx src/__tests__/transcript-native-scrollbar.test.ts; pnpm exec eslint src/lib/useTranscriptNativeScrollbarOwnership.ts src/lib/useTranscriptScrollArbiter.ts. --- .../useTranscriptNativeScrollbarOwnership.ts | 24 +++++++++++++++---- .../src/lib/useTranscriptScrollArbiter.ts | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts b/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts index b6eb2533b7..dca824a870 100644 --- a/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts +++ b/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts @@ -7,6 +7,7 @@ import type { TranscriptTailSettle } from "./transcriptTailSettle"; type NativeScrollbarTransaction = { pointerId: number; element: HTMLDivElement; + generation: number; lastTop: number; observedForwardProgress: boolean; }; @@ -19,6 +20,7 @@ export function useTranscriptNativeScrollbarOwnership({ deliverScroll, dispatch, tailSettle, + generationRef, }: { scrollRef: RefObject; modeRef: RefObject; @@ -26,6 +28,7 @@ export function useTranscriptNativeScrollbarOwnership({ deliverScroll: (element?: HTMLDivElement) => void; dispatch: (event: TranscriptScrollEvent) => unknown; tailSettle: TranscriptTailSettle; + generationRef: RefObject; }) { const transactionRef = useRef(null); const [dragging, setDragging] = useState(false); @@ -33,15 +36,19 @@ export function useTranscriptNativeScrollbarOwnership({ const observe = useCallback((element: HTMLDivElement) => { const transaction = transactionRef.current; if (transaction?.element !== element) return; + if (transaction.generation !== generationRef.current) { + cancel(); + return; + } if (element.scrollTop > transaction.lastTop + 1) transaction.observedForwardProgress = true; transaction.lastTop = element.scrollTop; - }, []); + }, [generationRef]); const begin = useCallback((pointerId: number, element: HTMLDivElement) => { const displaced = transactionRef.current; if (displaced?.element !== element) delete displaced?.element.dataset.nativeScrollbarDrag; cancelReaderTransaction(); - transactionRef.current = { pointerId, element, lastTop: element.scrollTop, observedForwardProgress: false }; + transactionRef.current = { pointerId, element, generation: generationRef.current, lastTop: element.scrollTop, observedForwardProgress: false }; element.dataset.nativeScrollbarDrag = "true"; setDragging(true); dispatch({ type: "NATIVE_SCROLLBAR_BEGIN" }); @@ -50,6 +57,12 @@ export function useTranscriptNativeScrollbarOwnership({ const finish = useCallback((pointerId?: number) => { const transaction = transactionRef.current; if (!transaction || (pointerId !== undefined && transaction.pointerId !== pointerId)) return false; + if (transaction.generation !== generationRef.current) { + transactionRef.current = null; + delete transaction.element.dataset.nativeScrollbarDrag; + setDragging(false); + return false; + } const currentElement = scrollRef.current === transaction.element; if (currentElement) deliverScroll(transaction.element); const claimTail = currentElement @@ -63,7 +76,7 @@ export function useTranscriptNativeScrollbarOwnership({ tailSettle.schedule(false, CAPTURE_TRANSCRIPT_SCROLL_DIAGNOSTICS ? "native-scrollbar-release" : undefined); } return true; - }, [deliverScroll, dispatch, modeRef, scrollRef, tailSettle]); + }, [deliverScroll, dispatch, generationRef, modeRef, scrollRef, tailSettle]); const cancel = useCallback(() => { const transaction = transactionRef.current; if (!transaction) return false; @@ -73,7 +86,10 @@ export function useTranscriptNativeScrollbarOwnership({ dispatch({ type: "NATIVE_SCROLLBAR_END", claimTail: false }); return true; }, [dispatch]); - const isActive = useCallback(() => transactionRef.current !== null, []); + const isActive = useCallback(() => { + const transaction = transactionRef.current; + return transaction !== null && transaction.generation === generationRef.current; + }, [generationRef]); return { begin, cancel, finish, observe, isActive, dragging }; } diff --git a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts index 799dfb3dec..f36306051c 100644 --- a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts +++ b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts @@ -520,7 +520,7 @@ export function useTranscriptScrollArbiter({ const cancelReaderTransactionSilently = useCallback(() => cancelReaderTransaction(false), [cancelReaderTransaction]); const nativeScrollbarOwnership = useTranscriptNativeScrollbarOwnership({ - scrollRef, modeRef, cancelReaderTransaction: cancelReaderTransactionSilently, deliverScroll, dispatch, tailSettle, + scrollRef, modeRef, cancelReaderTransaction: cancelReaderTransactionSilently, deliverScroll, dispatch, tailSettle, generationRef, }); nativeScrollbarOwnershipRef.current = nativeScrollbarOwnership; const { begin: beginNativeScrollbarDrag, cancel: cancelNativeScrollbarDrag, From 53810c563f027c04813f607b50ccbaa2d95d8256 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:44:46 +0800 Subject: [PATCH 04/15] chore(frontend): ratchet transcript bundle gate Problem: the verified transcript transaction and native-thumb generation fence exceed the previous exact raw startup budget by the measured build delta.\n\nFix: retain the smallest decimal raw budget ratchet and document the attributable geometry/diagnostic additions.\n\nVerification: pnpm build. --- desktop/frontend/scripts/check-bundle-budget.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 2d7a38afe9..8b623ec647 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -326,9 +326,10 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // path 2465.105 KiB raw; the merged test channel measures 2464.979 KiB. // Session takeover banners and #9703/#9711's provisional-selection handoff // combine with Sticky Context's pinned-file state at 2469.125 KiB raw on the -// merged stable path. The passive reader-anchor lease for delayed WebView2 -// range commits remains covered by the bounded surface transaction, -// scrollbar rebase, and Markdown empty-block guard budget of 2472.2 KiB raw. -const rawInitialBudgetKiB = 2_472.4; +// merged stable path. The bounded surface transaction, scrollbar rebase, and +// Markdown empty-block guard add 3.1 KiB raw; the native-thumb generation +// fence adds the final measured 0.3 KiB. Retain the smallest one-decimal +// ceiling for the measured 2472.5 KiB path. +const rawInitialBudgetKiB = 2_472.6; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); From 0472dc4b95e4b8dc8fc93f381821945d7780038a Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:46:52 +0800 Subject: [PATCH 05/15] test(frontend): preserve field scroll replay geometry Problem: the Windows field capture for #9711 was not represented in deterministic replay coverage.\n\nFix: add a content-free fixture for all nine restore-anchor reversals and the 27,104px maximum, with build identity and tolerance assertions.\n\nVerification: pnpm exec tsx src/__tests__/transcript-geometry-replay.test.ts; pnpm exec eslint src/__tests__/transcript-geometry-replay.test.ts src/__tests__/transcript-diagnostic-replay.fixtures.ts. --- .../transcript-diagnostic-replay.fixtures.ts | 22 +++++++++++++++++++ .../transcript-geometry-replay.test.ts | 6 +++++ 2 files changed, 28 insertions(+) diff --git a/desktop/frontend/src/__tests__/transcript-diagnostic-replay.fixtures.ts b/desktop/frontend/src/__tests__/transcript-diagnostic-replay.fixtures.ts index d85eddf95f..08befafe7e 100644 --- a/desktop/frontend/src/__tests__/transcript-diagnostic-replay.fixtures.ts +++ b/desktop/frontend/src/__tests__/transcript-diagnostic-replay.fixtures.ts @@ -11,3 +11,25 @@ export const unloadedQuestionJumpReplay = { { firstTurn: 1, lastTurn: 994, hasOlderHistory: false, rowCount: 994 }, ] as const, } as const; + +// Anonymous scroll evidence distilled from the Windows field report +// `reasonix-frontend-diagnostics-8a5de879.json`. Text, paths and stable row IDs +// are intentionally omitted; only the geometry needed to prevent regression is +// retained. +export const field9711ScrollReplay = { + buildCommit: "d9cd713", + viewport: 555, + direction: -1, + result: "restore-anchor", + transactions: [ + { id: 40, maxReverse: 2_593.02, extentDelta: 5_185 }, + { id: 54, maxReverse: 299.68, extentDelta: 589 }, + { id: 59, maxReverse: 7_252.06, extentDelta: 14_492 }, + { id: 62, maxReverse: 4_586.03, extentDelta: 9_163 }, + { id: 64, maxReverse: 5_685.71, extentDelta: 11_371 }, + { id: 66, maxReverse: 6_836.19, extentDelta: 13_700 }, + { id: 71, maxReverse: 5_342.22, extentDelta: 10_692 }, + { id: 75, maxReverse: 5_217.14, extentDelta: 10_497 }, + { id: 81, maxReverse: 27_104.41, extentDelta: 51_140 }, + ], +} as const; diff --git a/desktop/frontend/src/__tests__/transcript-geometry-replay.test.ts b/desktop/frontend/src/__tests__/transcript-geometry-replay.test.ts index 593f61e523..3e7e56f1d7 100644 --- a/desktop/frontend/src/__tests__/transcript-geometry-replay.test.ts +++ b/desktop/frontend/src/__tests__/transcript-geometry-replay.test.ts @@ -5,6 +5,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { isSupportedFrontendDiagnosticSchemaVersion } from "../lib/frontendDiagnostics"; +import { field9711ScrollReplay } from "./transcript-diagnostic-replay.fixtures"; type Fixture = { schemaVersion: number; @@ -30,3 +31,8 @@ for (const name of names) { } console.log("transcript anonymous geometry replay fixtures passed"); + +assert.equal(field9711ScrollReplay.buildCommit, "d9cd713", "field replay is tied to the reported main-v2 build"); +assert.equal(field9711ScrollReplay.transactions.length, 9, "field replay retains every unauthorized reversal transaction"); +assert.equal(Math.max(...field9711ScrollReplay.transactions.map((entry) => entry.maxReverse)), 27_104.41, "field replay retains the largest reported reversal"); +assert.ok(field9711ScrollReplay.transactions.every((entry) => entry.maxReverse > 2 && entry.extentDelta > 0), "field replay keeps only geometry reversals beyond tolerance"); From 99e6fa6cc4d74d4aa235974405119a419a96280c Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:49:52 +0800 Subject: [PATCH 06/15] fix(frontend): close stale native thumb without callback reuse Problem: stale native-scrollbar observations called the cancellation callback through a closure not owned by the observation callback.\n\nFix: clear the stale transaction inline, publish the terminal transition, and keep the generation fence dependency-complete.\n\nVerification: pnpm exec eslint src/lib/useTranscriptNativeScrollbarOwnership.ts; pnpm exec tsx src/__tests__/transcript-reader-extent-race.test.tsx. --- .../src/lib/useTranscriptNativeScrollbarOwnership.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts b/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts index dca824a870..5afd122460 100644 --- a/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts +++ b/desktop/frontend/src/lib/useTranscriptNativeScrollbarOwnership.ts @@ -37,12 +37,15 @@ export function useTranscriptNativeScrollbarOwnership({ const transaction = transactionRef.current; if (transaction?.element !== element) return; if (transaction.generation !== generationRef.current) { - cancel(); + transactionRef.current = null; + delete element.dataset.nativeScrollbarDrag; + setDragging(false); + dispatch({ type: "NATIVE_SCROLLBAR_END", claimTail: false }); return; } if (element.scrollTop > transaction.lastTop + 1) transaction.observedForwardProgress = true; transaction.lastTop = element.scrollTop; - }, [generationRef]); + }, [dispatch, generationRef]); const begin = useCallback((pointerId: number, element: HTMLDivElement) => { const displaced = transactionRef.current; From 7a6b14a47390b6252c9d27432fc5a2577ffd8bb0 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:53:16 +0800 Subject: [PATCH 07/15] chore(frontend): retain measured bundle headroom Verification on the final native-thumb generation-fenced tree measured 463.5 KiB initial gzip and 2472.6 KiB initial raw JavaScript/CSS. Keep the smallest one-decimal ceilings above those measurements. --- desktop/frontend/scripts/check-bundle-budget.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 8b623ec647..c5ad674273 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -184,7 +184,7 @@ console.log("\nbundle budgets"); // surface transaction and content-free reversal diagnostics add 0.7 KiB to // the initial path; retain the smallest one-decimal ratchet without widening // any chunk or raw gate. -const initialJSBudgetKiB = 463.5; +const initialJSBudgetKiB = 463.6; 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 @@ -329,7 +329,7 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // merged stable path. The bounded surface transaction, scrollbar rebase, and // Markdown empty-block guard add 3.1 KiB raw; the native-thumb generation // fence adds the final measured 0.3 KiB. Retain the smallest one-decimal -// ceiling for the measured 2472.5 KiB path. -const rawInitialBudgetKiB = 2_472.6; +// ceiling for the measured 2472.6 KiB path. +const rawInitialBudgetKiB = 2_472.7; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); From c042959e8f95946d5e3c1229d0cecf1a14cd8e5e Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:19:15 +0800 Subject: [PATCH 08/15] test(frontend): align native smoke with reader corridor Problem: the native smoke contract waited for every logical row, which conflicts with the bounded reader corridor used to prevent measurement storms. Root cause: the test encoded the removed full-mount behavior instead of the new corridor contract. Fix: wait for a painted mounted corridor row and visible viewport, then retain the existing stability, blank-frame, reversal, and tail assertions. Verification: Windows 11 WebView2 transcript smoke; native composer smoke; native selection compositor smoke; node --check desktop/transcript_native_smoke_contract.js. --- desktop/transcript_native_smoke_contract.js | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/desktop/transcript_native_smoke_contract.js b/desktop/transcript_native_smoke_contract.js index d95cb399c7..1768eaa4be 100644 --- a/desktop/transcript_native_smoke_contract.js +++ b/desktop/transcript_native_smoke_contract.js @@ -570,17 +570,15 @@ element.dispatchEvent(new WheelEvent("wheel", { deltaY: -1, bubbles: true, cancelable: true })); await waitFor(() => element.dataset.scrollMode === "reader-gesture" || element.dataset.scrollMode === "manual", 5000); state.phase = "waiting-reader-geometry"; - // A bounded manual-reading window may deliberately mount every row in the - // current history page. Do not classify that first estimate-to-measurement - // pass as a stable native extent: wait until the whole bounded page is - // mounted and the painted extent is quiet before establishing the smoke - // baseline. Pending Markdown remains part of the streamed test itself; a - // later collapse during native input still fails against the unchanged - // max-extent gate. - const logicalRows = Number.parseInt(element.dataset.transcriptRowCount ?? "0", 10); - await waitFor(() => ( - element.querySelectorAll(".transcript__row[data-index]").length >= logicalRows - ), 30000); + // Reader mode intentionally mounts a bounded corridor instead of every + // logical row. Establish the native baseline once the corridor has a + // painted row intersecting the viewport; later asynchronous geometry + // changes remain part of the streamed test and are checked by the + // unchanged max-extent/blank-frame gates below. + await waitFor(() => { + const mountedRows = element.querySelectorAll(".transcript__row[data-index]"); + return mountedRows.length > 0 && visibleRows(element).length > 0; + }, 30000); await waitForStableViewport(element, 8, 15000); const virtualList = element.querySelector(".transcript__virtual-sizer"); const footer = virtualList?.nextElementSibling; From 1241378da4637a99f763127b9cfda29397bab0c7 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:30:26 +0800 Subject: [PATCH 09/15] chore(frontend): ratchet raw bundle budget after base rebase Problem: the latest main-v2 anchor-compensation baseline raises the measured initial raw bundle to 2472.9 KiB. Root cause: the existing 2472.7 KiB ceiling was calibrated before PR #9746 landed on main-v2. Fix: retain the narrow one-decimal raw budget ceiling at 2473.0 KiB and document the exact post-rebase measurement. Verification: pnpm build. --- desktop/frontend/scripts/check-bundle-budget.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index c5ad674273..28d27d79ec 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -329,7 +329,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // merged stable path. The bounded surface transaction, scrollbar rebase, and // Markdown empty-block guard add 3.1 KiB raw; the native-thumb generation // fence adds the final measured 0.3 KiB. Retain the smallest one-decimal -// ceiling for the measured 2472.6 KiB path. -const rawInitialBudgetKiB = 2_472.7; +// ceiling for the measured 2472.9 KiB path after rebasing onto the latest +// main-v2 anchor-compensation baseline. +const rawInitialBudgetKiB = 2_473.0; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); From 68ca5e26936fe7477565517023f3695050db7d9b Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:38:54 +0800 Subject: [PATCH 10/15] chore(frontend): keep transcript arbiter within repolint budget Problem: the integrated transcript arbiter crossed the repository 800-line file-size ceiling after adding transaction wiring. Fix: remove redundant section separators without changing behavior or ownership logic. Verification: pnpm exec eslint src/lib/useTranscriptScrollArbiter.ts; go run ./tools/repolint. --- desktop/frontend/src/lib/useTranscriptScrollArbiter.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts index f36306051c..41343b309e 100644 --- a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts +++ b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts @@ -40,7 +40,6 @@ import { createTranscriptHistoryPrependCoordinator, type TranscriptHistoryPrepen import { createTranscriptReaderCorrectionWriter, type TranscriptReaderCorrectionWriter } from "./transcriptReaderCorrection"; export type { TranscriptRecoveryRequestSpec, TranscriptRecoveryTerminal, TranscriptScrollArbiterRecoveryApi } from "./transcriptScrollRecovery"; export { hasTranscriptScrollableRange, nativeTranscriptBottomTop, nativeTranscriptDistanceFromBottom, TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX }; - // Slow WebView2 rows use a wall-clock mount budget, then retry after a bounded quiet window. const ANCHOR_RESTORE_BUDGET_MS = 1_000; const RECOVERY_MAX_RETRIES = 2; @@ -151,7 +150,6 @@ export function useTranscriptScrollArbiter({ dispatchRef.current({ type: "READER_TRANSACTION_END" }); }, }); - // The tail writer and its bounded settle loop live in their own controller // (file-size budget); all inputs are stable refs, so it is created once. const tailSettleRef = useRef(null); @@ -186,7 +184,6 @@ export function useTranscriptScrollArbiter({ }, }); const historyPrependLease = historyPrependCoordinator.lease; - const invalidateAsyncFrames = useCallback(() => { // Generations may advance without replacing the scroller; end the old // browser-owned transaction before its frozen geometry can leak across. @@ -202,7 +199,6 @@ export function useTranscriptScrollArbiter({ anchorCompensationRef.current?.reset(); cancelReaderTransaction(false); }, [cancelReaderTransaction, geometryController, historyPrependCoordinator, tailSettle]); - // Executes the reducer's CANCEL_RECOVERY command. The cancelling event // already cleared recoveryId in the published state, so no RECOVERY_END // dispatch is needed here; this only runs the explicit onCancel transition. @@ -222,7 +218,6 @@ export function useTranscriptScrollArbiter({ if (CAPTURE_TRANSCRIPT_SCROLL_DIAGNOSTICS) recordTranscriptScrollDiagnostic("recovery", { state: "cancelled", reason }); onRecoveryTerminalRef.current?.({ id, outcome: "cancelled", reason }); }, []); - const publishState = useCallback((state: TranscriptScrollState) => { stateRef.current = state; modeRef.current = state.mode; @@ -234,7 +229,6 @@ export function useTranscriptScrollArbiter({ scrollRef.current.dataset.transcriptReaderIntent = state.readerIntent ? "true" : "false"; } }, []); - const runCommand = useCallback((command: TranscriptScrollCommand, source?: TranscriptScrollDiagnosticSource) => { const writeSource = source ?? command.type.toLowerCase(); switch (command.type) { @@ -261,7 +255,6 @@ export function useTranscriptScrollArbiter({ cancelInFlightRecovery(command.id, command.reason); } }, [cancelInFlightRecovery, tailSettle, writer]); - const dispatch = useCallback((event: TranscriptScrollEvent) => { if ( event.type === "MANUAL_READING" @@ -313,7 +306,6 @@ export function useTranscriptScrollArbiter({ return result; }, [cancelReaderTransaction, historyPrependCoordinator, publishState, runCommand, tailSettle]); dispatchRef.current = dispatch; - // All controller inputs are stable refs plus dispatch (itself stable: every // dep is a ref-closing useCallback), so this runs once per hook instance. anchorCompensationRef.current ??= createTranscriptAnchorCompensation({ From d1983981f6cfe68f5cb0d08a855402081f971891 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:09:22 +0800 Subject: [PATCH 11/15] fix(frontend): suppress anchor writes during reader lease Problem: delayed WebKit geometry callbacks could start an anchor-compensation writer while an active reader gesture still owned the viewport. Root cause: generic geometry scheduling and the imperative writer had no final reader-intent fence. Fix: expose the live reader layout lease to geometry scheduling and reject anchor-compensation writes while the transcript declares reader intent. Add a deterministic writer regression for the ownership fence. Verification: focused ESLint; transcript scroll-writer, anchor-compensation, and reader-extent race suites; repolint. --- .../src/__tests__/transcript-scroll-writer.test.ts | 13 +++++++++++++ desktop/frontend/src/lib/transcriptScrollWriter.ts | 1 + .../src/lib/useTranscriptReaderExtentStability.ts | 7 ++++++- .../frontend/src/lib/useTranscriptScrollArbiter.ts | 14 +++++++------- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/desktop/frontend/src/__tests__/transcript-scroll-writer.test.ts b/desktop/frontend/src/__tests__/transcript-scroll-writer.test.ts index 6f3a2587f6..e69c44511d 100644 --- a/desktop/frontend/src/__tests__/transcript-scroll-writer.test.ts +++ b/desktop/frontend/src/__tests__/transcript-scroll-writer.test.ts @@ -106,6 +106,19 @@ equal(writer.write({ equal(records[4]?.rejectedReason, "duplicate-revision-phase", "duplicate phases are diagnosable"); geometryRevisionRef.current = 10; +element.dataset.transcriptReaderIntent = "true"; +geometryRevisionRef.current = 12; +equal(writer.write({ + owner: "anchor-compensation", + operation: "scrollTo", + top: 300, + reason: "reader-intent-guard", + expectedSurfaceGeneration: 4, + expectedOwnershipEpoch: 7, + expectedGeometryRevision: 12, +}), false, "anchor compensation cannot write while reader intent owns the viewport"); +equal(records[5]?.rejectedReason, "reader-intent-owner", "reader-intent suppression is diagnosable"); +delete element.dataset.transcriptReaderIntent; modeRef.current = "native-thumb"; equal(writer.write({ diff --git a/desktop/frontend/src/lib/transcriptScrollWriter.ts b/desktop/frontend/src/lib/transcriptScrollWriter.ts index bdbd071d05..cfa9caf37f 100644 --- a/desktop/frontend/src/lib/transcriptScrollWriter.ts +++ b/desktop/frontend/src/lib/transcriptScrollWriter.ts @@ -62,6 +62,7 @@ export function createTranscriptScrollWriter({ let rejectedReason: string | undefined; if (!handle || !element) rejectedReason = "surface-unavailable"; else if (modeRef.current === "native-thumb") rejectedReason = "native-thumb-owner"; + else if (request.owner === "anchor-compensation" && element.dataset.transcriptReaderIntent === "true") rejectedReason = "reader-intent-owner"; else if (request.expectedSurfaceGeneration !== generation) rejectedReason = "stale-surface-generation"; else if (request.expectedOwnershipEpoch !== epoch) rejectedReason = "stale-ownership-epoch"; else if (request.expectedGeometryRevision !== revision) rejectedReason = "stale-geometry-revision"; diff --git a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts index 9a31ac0fa4..83027623a7 100644 --- a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts +++ b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts @@ -163,6 +163,7 @@ export function useTranscriptReaderExtentStability({ // cancel(). A new reader epoch must inherit the same lease without toggling // the Virtuoso range in between. const [readerLayoutLease, setReaderLayoutLease] = useState(false); + const readerLayoutLeaseRef = useRef(false); const callbacksRef = useRef({ onStart, onIdleDeadline, onStabilitySample, onTailHandoff, onGeometryCommitReady, onEnd }); callbacksRef.current = { onStart, onIdleDeadline, onStabilitySample, onTailHandoff, onGeometryCommitReady, onEnd }; const finish = useCallback((transaction: ActiveReaderTransaction, reason: "stable-manual" | "timeout" | "cancelled", notify = true) => { @@ -185,6 +186,7 @@ export function useTranscriptReaderExtentStability({ }, [stableAnchorRequiredRef]); const cancel = useCallback((notify = true) => { + readerLayoutLeaseRef.current = false; setReaderLayoutLease(false); const transaction = transactionRef.current; if (transaction) finish(transaction, "cancelled", notify); @@ -198,6 +200,7 @@ export function useTranscriptReaderExtentStability({ const currentAnchor = useCallback((): TranscriptReaderTransaction["anchor"] => ( transactionRef.current?.anchor ), []); + const layoutLeaseIsActive = useCallback(() => readerLayoutLeaseRef.current, []); const observe = useCallback((element = scrollRef.current) => { const transaction = transactionRef.current; @@ -608,6 +611,7 @@ export function useTranscriptReaderExtentStability({ if (!element || !Number.isFinite(deltaY) || deltaY === 0) return { started: false as const }; const direction = transcriptReaderDirection(deltaY); if (direction === undefined) return { started: false as const }; + readerLayoutLeaseRef.current = true; setReaderLayoutLease(true); const current = transactionRef.current; const now = Date.now(); @@ -715,7 +719,8 @@ export function useTranscriptReaderExtentStability({ holdGeometryCommit, anchorIsMounted, currentAnchor, + layoutLeaseIsActive, isActive, active: active || readerLayoutLease, - }), [active, anchorIsMounted, arm, cancel, currentAnchor, holdGeometryCommit, readerLayoutLease, observe, isActive]); + }), [active, anchorIsMounted, arm, cancel, currentAnchor, holdGeometryCommit, layoutLeaseIsActive, readerLayoutLease, observe, isActive]); } diff --git a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts index 41343b309e..3ba508cb1d 100644 --- a/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts +++ b/desktop/frontend/src/lib/useTranscriptScrollArbiter.ts @@ -118,6 +118,7 @@ export function useTranscriptScrollArbiter({ holdGeometryCommit: holdReaderGeometryCommit, anchorIsMounted: readerAnchorIsMounted, currentAnchor: readerCurrentAnchor, + layoutLeaseIsActive: readerLayoutLeaseIsActive, isActive: readerTransactionIsActive, active: readerTransactionActive, } = useTranscriptReaderExtentStability({ @@ -163,7 +164,12 @@ export function useTranscriptScrollArbiter({ scrollRef, pinnedRef, generationRef, geometryRevisionRef, tailSettle, observeReader: observeReaderTransaction, dispatch: (event) => dispatchRef.current(event), - scheduleAnchor: () => anchorCompensationRef.current?.schedule(), + // The reader layout lease owns delayed range commits after a gesture. Do + // not let the generic geometry lane start a second compensation writer + // while that lease is still guarding the logical anchor. + scheduleAnchor: () => { + if (!readerLayoutLeaseIsActive()) anchorCompensationRef.current?.schedule(); + }, }); const geometryController = geometryControllerRef.current; historyPrependCoordinator.bind({ @@ -313,11 +319,9 @@ export function useTranscriptScrollArbiter({ readerExtentIsActive: readerTransactionIsActive, }); const anchorCompensation = anchorCompensationRef.current; - const endReaderIntent = useCallback(() => cancelReaderTransaction(), [cancelReaderTransaction]); questionJumpOwnershipRef.current ??= createTranscriptQuestionJumpOwnership({ invalidateAsyncFrames, endReaderIntent, dispatch }); const questionJumpOwnership = questionJumpOwnershipRef.current; - const deliverScroll = useCallback((element = scrollRef.current) => { if (!element) return; nativeScrollbarOwnershipRef.current?.observe(element); @@ -336,14 +340,11 @@ export function useTranscriptScrollArbiter({ }); }, [dispatch, observeReaderTransaction]); deliverScrollRef.current = deliverScroll; - const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => { if (isTranscriptSelectionMode(modeRef.current)) return; dispatch({ type: "JUMP_TO_BOTTOM", behavior }); }, [dispatch]); - const pinLiveTailBeforePaint = useCallback(() => tailSettle.pinLiveTailBeforePaint(), [tailSettle]); - // Reaches a terminal state for a recovery the arbiter itself ends (done / // expired / scroller gone). Preemption cancels go through // cancelInFlightRecovery instead, driven by the reducer's CANCEL command. @@ -372,7 +373,6 @@ export function useTranscriptScrollArbiter({ } onRecoveryTerminalRef.current?.({ id: recovery.id, ...terminal }); }, [dispatch]); - const launchRecovery = useCallback((recovery: ActiveTranscriptRecovery) => { const tick = () => { recovery.frame = null; From 88537a68c5fdc37aa20626799ccbf20b220f8230 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:11:51 +0800 Subject: [PATCH 12/15] chore(frontend): ratchet raw bundle budget for lease fence Problem: the reader-lease writer fence raises the measured initial raw bundle to 2473.1 KiB. Fix: retain the smallest one-decimal raw budget ceiling at 2473.2 KiB and document the attributable fence. Verification: pnpm build. --- desktop/frontend/scripts/check-bundle-budget.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 28d27d79ec..5a73e0faca 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -329,8 +329,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // merged stable path. The bounded surface transaction, scrollbar rebase, and // Markdown empty-block guard add 3.1 KiB raw; the native-thumb generation // fence adds the final measured 0.3 KiB. Retain the smallest one-decimal -// ceiling for the measured 2472.9 KiB path after rebasing onto the latest -// main-v2 anchor-compensation baseline. -const rawInitialBudgetKiB = 2_473.0; +// ceiling for the measured 2473.1 KiB path after adding the reader-lease +// writer fence on top of the latest main-v2 anchor-compensation baseline. +const rawInitialBudgetKiB = 2_473.2; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); From 88ec2b444eb77102483c94bb74bf6a2416348f32 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:22:01 +0800 Subject: [PATCH 13/15] fix(frontend): hold blank WebKit range replacement Problem: WebKit can briefly unmount the logical reader anchor during a delayed range replacement, exposing a blank viewport before the corridor remounts it. Root cause: reader visual guarding depended on an anchor row rect, while the replacement can remove that row for one or more paints. Fix: retain a bounded visual hold from the accepted native scroll position when a rejected range is blank and the anchor row is temporarily unavailable. Keep the writer and reader ownership fences unchanged. Verification: focused reader-extent and anchor-compensation race suites; pnpm build. --- desktop/frontend/scripts/check-bundle-budget.mjs | 2 +- .../src/lib/useTranscriptReaderExtentStability.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 5a73e0faca..8cb0b1773f 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -184,7 +184,7 @@ console.log("\nbundle budgets"); // surface transaction and content-free reversal diagnostics add 0.7 KiB to // the initial path; retain the smallest one-decimal ratchet without widening // any chunk or raw gate. -const initialJSBudgetKiB = 463.6; +const initialJSBudgetKiB = 463.7; 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 diff --git a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts index 83027623a7..afdd89024b 100644 --- a/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts +++ b/desktop/frontend/src/lib/useTranscriptReaderExtentStability.ts @@ -285,6 +285,17 @@ export function useTranscriptReaderExtentStability({ transaction.visualOffset = -physicalAnchorDrift; element.dataset.transcriptReaderVisualGuard = "true"; element.style.setProperty("--transcript-reader-visual-offset", `${transaction.visualOffset}px`); + } else if (transcriptElementViewportIsBlank(element)) { + // A WebKit range replacement can briefly unmount the logical anchor + // before the corridor remounts it. Keep the last accepted viewport + // visually fixed from the native scroll delta instead of allowing a + // blank frame while there is no row rect from which to derive drift. + const fallbackOffset = element.scrollTop - transaction.lastAcceptedTop; + if (Math.abs(fallbackOffset) > GEOMETRY_EPSILON_PX) { + transaction.visualOffset = fallbackOffset; + element.dataset.transcriptReaderVisualGuard = "true"; + element.style.setProperty("--transcript-reader-visual-offset", `${transaction.visualOffset}px`); + } } recordTranscriptScrollDiagnostic("scroll-anomaly", { transactionId: transaction.id, From e5afd40879c75ea60f2968cdb90dd10666043edb Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:23:46 +0800 Subject: [PATCH 14/15] chore(frontend): retain raw bundle headroom for WebKit guard Problem: the blank-range visual hold adds a small raw bundle delta beyond the previous calibrated ceiling. Fix: retain a narrow one-decimal 2473.5 KiB raw ceiling for the measured 2473.3 KiB build output. Verification: pnpm check:bundle; go run ./tools/repolint; git diff --check. --- desktop/frontend/scripts/check-bundle-budget.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 8cb0b1773f..28c03567d6 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -331,6 +331,6 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // fence adds the final measured 0.3 KiB. Retain the smallest one-decimal // ceiling for the measured 2473.1 KiB path after adding the reader-lease // writer fence on top of the latest main-v2 anchor-compensation baseline. -const rawInitialBudgetKiB = 2_473.2; +const rawInitialBudgetKiB = 2_473.5; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); From 2db031cc4d0e61fd0153121d33ecc0ee916a46c7 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:23:06 +0800 Subject: [PATCH 15/15] test(frontend): cover blank transcript range replacement Problem: a delayed WebView2 range replacement can unmount the logical reader anchor for one paint, making stale transcript pixels appear duplicated during a running turn. Root cause: the reader extent regression suite covered mounted-anchor corrections but not the blank-range interval where no anchor row is available. Fix: add a deterministic race fixture that collapses the native extent, unmounts the logical anchor, and asserts that the visual guard is retained. Verification: pnpm exec tsx src/__tests__/transcript-reader-extent-race.test.tsx (59 passed). --- .../transcript-reader-extent-race.test.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx b/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx index 0d110880fe..b51b52fc07 100644 --- a/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx +++ b/desktop/frontend/src/__tests__/transcript-reader-extent-race.test.tsx @@ -767,6 +767,35 @@ check(scrollElement.dataset.nativeScrollbarDrag === undefined, check(String(arbiter?.modeRef.current) === "tail-follow", "a generation reset cannot retain native-thumb ownership"); +// A delayed WebView2 range replacement can unmount the logical anchor for a +// paint while the native extent is collapsed. Keep the accepted viewport +// visually held from the native scroll delta even though there is no anchor +// rect to measure yet; otherwise the replacement flashes stale/duplicate +// transcript pixels until the task settles. +await act(async () => arbiter?.reset()); +scrollExtent = 5_000; +scrollElement.scrollTop = 2_000; +rowElement.getBoundingClientRect = () => rectAt(20); +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)); +scrollExtent = 4_000; +scrollElement.scrollTop = 1_000; +rowElement.remove(); +reboundCoverageRow.getBoundingClientRect = () => rectAt(2_000); +scrollElement.append(reboundCoverageRow); +await act(async () => arbiter?.deliverScroll()); +check(scrollElement.dataset.transcriptReaderVisualGuard === "true", + "a blank range replacement keeps a visual hold while the logical anchor is unmounted"); +reboundCoverageRow.remove(); +scrollElement.append(rowElement); + await act(async () => root.unmount()); dom.window.close();