diff --git a/desktop/frontend/src/__tests__/transcript-kernel-races.test.ts b/desktop/frontend/src/__tests__/transcript-kernel-races.test.ts new file mode 100644 index 0000000000..fa261f6538 --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-kernel-races.test.ts @@ -0,0 +1,231 @@ +import { + TranscriptKernel, + type TranscriptKernelClock, + type TranscriptKernelEvent, + type TranscriptViewportSnapshot, + type TranscriptWriteRequest, +} from "../lib/transcriptKernel"; +import { observeTranscriptGeometry } from "../lib/transcriptGeometryObserver"; + +let passed = 0; +let failed = 0; +function ok(condition: unknown, label: string) { + if (condition) { process.stdout.write(` PASS ${label}\n`); passed += 1; } + else { process.stdout.write(` FAIL ${label}\n`); failed += 1; } +} + +class FakeClock implements TranscriptKernelClock { + time = 0; + sequence = 0; + frames = new Map(); + timers = new Map void }>(); + now = () => this.time; + requestAnimationFrame = (callback: FrameRequestCallback) => { + const id = ++this.sequence; + this.frames.set(id, callback); + return id; + }; + cancelAnimationFrame = (id: number) => { this.frames.delete(id); }; + setTimeout = (callback: () => void, delay: number) => { + const id = ++this.sequence; + this.timers.set(id, { at: this.time + delay, callback }); + return id as unknown as ReturnType; + }; + clearTimeout = (id: ReturnType) => { this.timers.delete(id as unknown as number); }; + flushFrames() { + const frames = [...this.frames.values()]; + this.frames.clear(); + frames.forEach((callback) => callback(this.time)); + } + advance(ms: number) { + this.time += ms; + const ready = [...this.timers].filter(([, timer]) => timer.at <= this.time); + ready.forEach(([id, timer]) => { + this.timers.delete(id); + timer.callback(); + }); + } +} + +const readerSnapshot = (key = "turn:visible", scrollTop = 500): TranscriptViewportSnapshot => ({ + scrollTop, + scrollHeight: 4_000, + clientHeight: 800, + visibleBlocks: [{ key, top: scrollTop - 12, bottom: scrollTop + 120 }], +}); + +function setup(session = "race") { + const clock = new FakeClock(); + const writes: TranscriptWriteRequest[] = []; + const events: TranscriptKernelEvent[] = []; + const kernel = new TranscriptKernel({ clock, emit: (event) => events.push(event) }); + kernel.connectWriter((request) => { + writes.push(request); + return { accepted: true, offset: Number.isFinite(request.offset) ? request.offset : 3_200, changed: true }; + }); + kernel.replaceSurface(session); + return { clock, events, kernel, writes }; +} + +console.log("\nTranscriptKernel deterministic race matrix"); + +{ + const { clock, kernel, writes } = setup("observer-A"); + let notify!: () => void; + let before = 0, commits = 0; + const disconnect = observeTranscriptGeometry(kernel, {} as Element, + () => { before += 1; }, () => { commits += 1; kernel.advanceGeometry(); }, + (callback) => { notify = callback; return { observe() {}, disconnect() {} }; }); + notify(); + const queued = [...clock.frames.values()]; + disconnect(); + kernel.replaceSurface("observer-B"); + notify(); // A platform can deliver notifications even after disconnect. + queued.forEach((callback) => callback(0)); + clock.flushFrames(); + ok(before === 1 && commits === 0 && kernel.geometryRevision === 0 && writes.length === 0, + "detached observer and already queued frame cannot mutate replacement geometry"); + let painted = false; + const cancel = kernel.afterCurrentGenerationPaint(() => { painted = true; }); + const cancelledFrame = [...clock.frames.values()][0]; + cancel(); + cancelledFrame(0); + ok(!painted, "a cancelled surface-paint callback is inert even when delivered in the same generation"); + kernel.renewNativeGesture(readerSnapshot(), 320, () => {}); + const staleTimer = [...clock.timers.values()][0].callback; + kernel.replaceSurface("observer-C"); + kernel.renewNativeGesture(readerSnapshot(), 320, () => {}); + staleTimer(); + ok(kernel.nativeGestureLeaseActive && kernel.userGestureActive, + "an old native timer cannot release the new generation's lease"); +} + +{ + const { clock, kernel, writes } = setup("stream-display"); + kernel.scheduleTailSync(); + const display = kernel.begin("display-change", { kind: "block", blockKey: "turn:4", offsetPx: 3 }); + kernel.advanceGeometry(); + ok(Boolean(display && kernel.correctAnchor(display, () => 700)), "stream growth × display change commits from the newest geometry"); + clock.flushFrames(); + ok(writes.length === 1 && writes[0]?.offset === 703, "display change cancels its queued tail write and performs one correction"); +} + +{ + const { kernel, writes } = setup("display-gesture"); + const display = kernel.begin("display-change", { kind: "block", blockKey: "turn:4", offsetPx: 0 }); + kernel.beginUserGesture(readerSnapshot()); + kernel.advanceGeometry(); + ok(display?.status === "cancelled" && !kernel.correctAnchor(display!, () => 800), "display change × wheel/touch/thumb gives ownership to the user"); + ok(writes.length === 0, "a held native gesture accepts zero programmatic writes"); +} + +{ + const { kernel, writes } = setup("prepend-selection"); + kernel.observeNativeScroll(readerSnapshot()); + const prepend = kernel.begin("prepend", kernel.anchor); + kernel.beginUserGesture(readerSnapshot(), "selection"); + kernel.advanceGeometry(); + ok(prepend?.status === "cancelled" && !kernel.correctAnchor(prepend!, () => 900), "prepend × selection cancels the structural correction"); + kernel.writeUserControlled("selection-edge-scroll", 520); + ok(writes.length === 1 && writes[0]?.owner === "selection-edge-scroll", "selection keeps only its explicit edge-scroll write"); + kernel.endUserGesture(); + ok(kernel.activeTransaction === null, "selection reaches a terminal state when the gesture ends"); +} + +{ + const { kernel, writes } = setup("prepend-during-gesture"); + const snapshot = readerSnapshot("turn:prepend-anchor"); + kernel.beginUserGesture(snapshot, "selection"); + const deferred = kernel.begin("prepend", kernel.anchor); + ok(deferred === null && writes.length === 0, "prepend requested during a gesture captures intent without writing"); + const resumed = kernel.endUserGesture(); + kernel.advanceGeometry(); + kernel.correctAnchor(resumed!, () => 1_400); + ok(resumed?.status === "committed" && writes[0]?.offset === 1_412, "gesture release resumes prepend from the pre-mutation logical anchor"); +} + +{ + const { kernel, writes } = setup("prepend-display"); + kernel.observeNativeScroll(readerSnapshot("turn:stable")); + const prepend = kernel.begin("prepend", kernel.anchor); + const display = kernel.begin("display-change", kernel.anchor); + kernel.advanceGeometry(); + kernel.correctAnchor(display!, () => 880); + ok(prepend?.status === "cancelled" && display?.status === "committed", "prepend × display change deterministically selects the latest equal-priority transaction"); + ok(writes.length === 1 && writes[0]?.offset === 892, "the surviving transaction preserves the reader's in-block offset"); +} + +{ + const { clock, kernel, writes } = setup("composer-tail"); + kernel.scheduleTailSync(); + const composer = kernel.begin("composer-resize", { kind: "tail" }); + kernel.advanceGeometry(); + kernel.correctAnchor(composer!, () => undefined); + clock.flushFrames(); + ok(composer?.status === "committed" && writes.length === 1, "Composer resize × tail follow submits one tail correction"); +} + +{ + const { kernel, writes } = setup("jump-switch"); + const jump = kernel.stageJumpToBlock("turn:900"); + kernel.replaceSurface("jump-destination"); + kernel.advanceGeometry(); + ok(jump?.status === "cancelled" && !kernel.correctAnchor(jump!, () => 12_000), "question jump × session switch fences the old generation"); + ok(writes.length === 0, "a stale question jump performs zero writes"); +} + +{ + const { clock, kernel, writes } = setup("turn-completion"); + kernel.scheduleTailSync(); + kernel.scheduleTailSync(); + clock.flushFrames(); + ok(writes.length === 1 && writes[0]?.generation === kernel.generation, "active completion × next-round start coalesces to one current-generation tail write"); +} + +{ + const { kernel, writes } = setup("lazy-measure"); + kernel.observeNativeScroll(readerSnapshot("turn:markdown")); + const restore = kernel.begin("restore", kernel.anchor); + kernel.advanceGeometry(); + ok(!kernel.correctAnchor(restore!, () => undefined), "lazy Markdown/image/table measurement defers when the anchor is unmeasured"); + ok(!kernel.correctAnchor(restore!, () => 910), "one geometry revision accepts at most one structural correction attempt"); + kernel.advanceGeometry(); + ok(kernel.correctAnchor(restore!, () => 910), "the latest measured geometry retries the logical reader anchor once"); + ok(writes[0]?.offset === 922, "lazy content restores the exact block-local reader offset"); + kernel.observeNativeScroll(readerSnapshot("turn:wrong", 922)); + ok(kernel.anchor.kind === "block" && kernel.anchor.blockKey === "turn:markdown", "writer scroll events cannot replace the structural logical anchor"); + kernel.beginUserGesture(readerSnapshot("turn:user", 940)); + kernel.endUserGesture(); + ok(kernel.anchor.kind === "block" && kernel.anchor.blockKey === "turn:user", "the next native scroll records the user's actual reader anchor"); +} + +{ + const { kernel } = setup("gesture-anchor-ownership"); + kernel.observeNativeScroll(readerSnapshot("turn:reader", 500)); + kernel.beginUserGesture(readerSnapshot("turn:reader", 500)); + kernel.endUserGesture(); + ok(kernel.anchor.kind === "block" && kernel.anchor.blockKey === "turn:reader", "measurement-only gesture completion preserves the pre-measurement logical anchor"); + kernel.beginUserGesture(readerSnapshot("turn:reader", 500)); + kernel.observeNativeScroll(readerSnapshot("turn:moved", 620)); + kernel.endUserGesture(); + ok(kernel.anchor.kind === "block" && kernel.anchor.blockKey === "turn:moved", "a changed native position commits the gesture's final logical anchor"); +} + +{ + const { kernel, writes } = setup("reduced-motion"); + const restore = kernel.begin("restore", { kind: "block", blockKey: "turn:old", offsetPx: 0 }); + kernel.replaceSurface("reduced-motion-replacement"); + kernel.advanceGeometry(); + ok(restore?.status === "cancelled" && writes.length === 0, "reduced-motion × surface replacement keeps the same generation fence and zero-write path"); +} + +{ + const { kernel, events } = setup("safe-mode"); + kernel.reportAnomaly("blank-viewport"); + kernel.reportAnomaly("invalid-geometry"); + ok(kernel.safeMode, "two consecutive geometry anomalies activate full-DOM safe mode"); + ok(events.filter((event) => event.outcome === "blank-viewport" || event.outcome === "invalid-geometry").length === 2, "safe-mode anomalies remain numeric/enumerated diagnostics"); +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/desktop/frontend/src/__tests__/transcript-kernel.test.ts b/desktop/frontend/src/__tests__/transcript-kernel.test.ts new file mode 100644 index 0000000000..a6c7e2aed9 --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-kernel.test.ts @@ -0,0 +1,162 @@ +import { TranscriptMeasurementLedger } from "../lib/transcriptMeasurementLedger"; +import { TranscriptKernel, type TranscriptKernelClock, type TranscriptKernelEvent } from "../lib/transcriptKernel"; + +let passed = 0; +let failed = 0; +function ok(condition: unknown, label: string) { + if (condition) { process.stdout.write(` PASS ${label}\n`); passed += 1; } + else { process.stdout.write(` FAIL ${label}\n`); failed += 1; } +} + +class FakeClock implements TranscriptKernelClock { + time = 0; + sequence = 0; + frames = new Map(); + timers = new Map void }>(); + now = () => this.time; + requestAnimationFrame = (callback: FrameRequestCallback) => { const id = ++this.sequence; this.frames.set(id, callback); return id; }; + cancelAnimationFrame = (id: number) => { this.frames.delete(id); }; + setTimeout = (callback: () => void, delay: number) => { const id = ++this.sequence; this.timers.set(id, { at: this.time + delay, callback }); return id as unknown as ReturnType; }; + clearTimeout = (id: ReturnType) => { this.timers.delete(id as unknown as number); }; + flushFrames() { const frames = [...this.frames.values()]; this.frames.clear(); frames.forEach((callback) => callback(this.time)); } + advance(ms: number) { + this.time += ms; + const ready = [...this.timers].filter(([, timer]) => timer.at <= this.time); + ready.forEach(([id, timer]) => { this.timers.delete(id); timer.callback(); }); + } +} + +console.log("\nTranscriptKernel deterministic transactions"); +const clock = new FakeClock(); +const events: TranscriptKernelEvent[] = []; +const writes: Array<{ generation: number; transactionId: number; offset: number; owner: string }> = []; +const kernel = new TranscriptKernel({ clock, emit: (event) => events.push(event) }); +kernel.connectWriter((request) => { + writes.push(request); + return { accepted: true, offset: Number.isFinite(request.offset) ? request.offset : 900, changed: true }; +}); + +kernel.replaceSurface("one"); +const restore = kernel.begin("restore", { kind: "block", blockKey: "turn:2", offsetPx: 7 }); +ok(Boolean(restore), "restore transaction begins in the current generation"); +kernel.advanceGeometry(); +ok(Boolean(restore && kernel.correctAnchor(restore, () => 120)), "logical block anchor commits one correction"); +ok(writes[writes.length - 1]?.offset === 127, "block correction preserves its in-block offset"); +ok(restore?.status === "committed", "accepted correction reaches a terminal committed state"); + +const display = kernel.begin("display-change", { kind: "block", blockKey: "turn:2", offsetPx: 7 }); +const lowerTail = kernel.begin("tail-sync"); +ok(lowerTail === null, "tail follow cannot supersede display change"); +const jump = kernel.begin("jump", { kind: "block", blockKey: "turn:9", offsetPx: 0 }); +ok(display?.status === "cancelled" && jump?.status === "active", "question jump supersedes lower-priority display work"); + +const snapshot = { + scrollTop: 200, scrollHeight: 2_000, clientHeight: 500, + visibleBlocks: [{ key: "turn:4", top: 180, bottom: 280 }], +}; +kernel.beginUserGesture(snapshot); +ok(jump?.status === "cancelled" && kernel.intent === "reader", "native user intent cancels an active jump and owns reader intent"); +const countBeforeGesture = writes.length; +kernel.scheduleTailSync(); +clock.flushFrames(); +ok(writes.length === countBeforeGesture, "reader gesture accepts zero tail writes"); +kernel.endUserGesture(); + +// Deferred DOM growth is reconciled after native release, preserving the +// original logical anchor while allowing the following block to move. +const measured = new TranscriptMeasurementLedger(); +measured.commit([{ key: "before", size: 100 }, { key: "turn:4", size: 100 }]); +kernel.beginUserGesture(snapshot); +measured.beginUnboundedGesture(); +measured.stage([{ key: "before", size: 180 }, { key: "turn:4", size: 340 }]); +const heldWrites = writes.length; +measured.publishStaged(() => measured.publicationLead(kernel.userGestureActive) === 0); +ok(measured.sizeFor("turn:4", 0) === 100 && writes.length === heldWrites, "held growth remains staged with zero correction writes"); +kernel.endUserGesture(); +measured.endGesture(); +const reconciliation = kernel.begin("restore", kernel.anchor); +measured.publishStaged(); +kernel.advanceGeometry(); +const newAnchorTop = snapshot.visibleBlocks[0].top + measured.sizeFor("before", 0) - 100; +if (reconciliation) kernel.correctAnchor(reconciliation, () => newAnchorTop); +ok(writes[writes.length - 1]?.offset === 280, "release corrects only the changed prefix and retains the reader's 20px in-block offset"); +ok(newAnchorTop + measured.sizeFor("turn:4", 0) === 600, "the following block advances past all expanded content"); +const settledWrites = writes.length; +if (reconciliation) kernel.correctAnchor(reconciliation, () => newAnchorTop); +ok(writes.length === settledWrites, "one geometry reconciliation cannot emit duplicate corrections"); + +kernel.scrollToTail(); +const writesBeforeStaleFrame = writes.length; +kernel.scheduleTailSync(); +kernel.replaceSurface("two"); +clock.flushFrames(); +ok(writes.length === writesBeforeStaleFrame, "a queued callback from an expired generation performs zero writes"); + +kernel.scheduleTailSync(); +const writesBeforeDetach = writes.length; +kernel.detachSurface(); +clock.flushFrames(); +ok(writes.length === writesBeforeDetach, "a queued callback from an unmounted surface performs zero writes"); + +const expiring = kernel.begin("prepend", { kind: "block", blockKey: "missing", offsetPx: 0 }); +clock.advance(1_000); +ok(expiring?.status === "expired", "a transaction that cannot settle expires deterministically at 1000ms"); +ok(events.some((event) => event.transaction === expiring?.id && event.outcome === "deadline"), "expiry emits an explicit terminal outcome"); + +kernel.reportAnomaly("blank-viewport"); +ok(!kernel.safeMode, "one anomalous frame does not downgrade the session"); +kernel.reportHealthyGeometry(); +kernel.reportAnomaly("invalid-geometry"); +ok(!kernel.safeMode, "a healthy frame resets the consecutive anomaly streak"); +kernel.reportAnomaly("blank-viewport"); +ok(kernel.safeMode, "two consecutive anomalies downgrade only the current generation"); +kernel.replaceSurface("three"); +ok(!kernel.safeMode, "surface generation replacement clears safe mode"); + +let leaseEnded = 0; +kernel.renewNativeGesture(snapshot, 320, () => { leaseEnded += 1; }); +ok(kernel.userGestureActive && kernel.nativeGestureLeaseActive, "native input starts one kernel-owned gesture lease"); +clock.advance(319); +ok(leaseEnded === 0 && kernel.userGestureActive, "the injected clock keeps native ownership until the lease expires"); +kernel.renewNativeGesture({ ...snapshot, scrollTop: 260 }, 320, () => { leaseEnded += 1; }); +clock.advance(319); +ok(leaseEnded === 0, "renewing native input replaces rather than stacks lease timers"); +clock.advance(1); +ok(leaseEnded === 1 && !kernel.userGestureActive && !kernel.nativeGestureLeaseActive, "the current generation ends the gesture exactly once"); + +kernel.renewNativeGesture(snapshot, 320, () => { leaseEnded += 1; }); +kernel.replaceSurface("four"); +clock.advance(320); +ok(leaseEnded === 1 && !kernel.userGestureActive, "surface replacement cancels stale gesture callbacks"); + +let painted = 0; +kernel.afterCurrentGenerationPaint(() => { painted += 1; }); +kernel.replaceSurface("five"); +clock.flushFrames(); +ok(painted === 0, "surface replacement cancels stale paint callbacks"); +kernel.afterCurrentGenerationPaint(() => { painted += 1; }); +clock.flushFrames(); +ok(painted === 1, "the current generation accepts its paint callback"); + +kernel.scrollToTail(); +const delayedWriterOffset = 900; +kernel.beginUserGesture({ + ...snapshot, + scrollTop: delayedWriterOffset, + visibleBlocks: [{ key: "turn:writer-target", top: delayedWriterOffset, bottom: delayedWriterOffset + 120 }], +}); +const delayedWriterIsNative = kernel.observeNativeScroll({ + ...snapshot, + scrollTop: delayedWriterOffset, + visibleBlocks: [{ key: "turn:writer-target", top: delayedWriterOffset, bottom: delayedWriterOffset + 120 }], +}); +ok(!delayedWriterIsNative, "a delayed writer scroll keeps its provenance after user ownership begins"); +const movedNativeIsNative = kernel.observeNativeScroll({ + ...snapshot, + scrollTop: delayedWriterOffset - 80, + visibleBlocks: [{ key: "turn:user-position", top: delayedWriterOffset - 90, bottom: delayedWriterOffset + 30 }], +}); +ok(movedNativeIsNative, "a physical offset that diverges from the writer target belongs to the user"); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/desktop/frontend/src/__tests__/transcript-measurement-ledger.test.ts b/desktop/frontend/src/__tests__/transcript-measurement-ledger.test.ts new file mode 100644 index 0000000000..293315e84e --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-measurement-ledger.test.ts @@ -0,0 +1,85 @@ +import { TranscriptMeasurementLedger } from "../lib/transcriptMeasurementLedger"; + +let passed = 0; +let failed = 0; +function ok(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 immutable measurement ledger"); + +const ledger = new TranscriptMeasurementLedger(); +ok(ledger.publicationLead(false) === 0, "an idle adapter has no measurement publication lead"); +ok(ledger.publicationLead(true) === Number.POSITIVE_INFINITY, "an unclassified native gesture freezes every cold measurement"); +ledger.observeWheel(2_880, 0, 596); +ok(ledger.publicationLead(true) === 3_476, "pixel wheel input reserves one native step plus one viewport"); +ledger.observeWheel(120, 0, 596); +ok(ledger.publicationLead(true) === 3_596, "a wheel lease accumulates every unsettled native compositor step"); +ledger.observeViewport(1000); +ledger.observeViewport(3880); +ok(ledger.publicationLead(true) === 716, "observed native progress retires only consumed travel and retains one viewport plus the pending step"); +ledger.observeViewport(4000); +ok(ledger.publicationLead(true) === 596, "fully consumed wheel input still protects one viewport of compositor runway"); +for (let step = 0; step < 100; step += 1) { + ledger.observeWheel(120, 0, 596); + ledger.observeViewport(4000 + (step + 1) * 120); +} +ok(ledger.publicationLead(true) === 596, "sustained native input cannot accumulate already-consumed distance into permanent measurement debt"); +ledger.beginUnboundedGesture(); +ok(ledger.publicationLead(true) === Number.POSITIVE_INFINITY, "touch, selection, thumb, or keyboard takeover upgrades a bounded lease to unbounded"); +ok(ledger.publicationLead(false) === Number.POSITIVE_INFINITY, "native ownership freezes publication before React commits the kernel snapshot"); +ledger.endGesture(); +ledger.observeWheel(80, 0, 596); +ok(ledger.publicationLead(true) === 676, "gesture completion resets the prior publication lead"); +ok(ledger.publicationLead(false) === 676, "bounded native input protects publication before React commits its gesture snapshot"); +ledger.endGesture(); +ledger.observeWheel(18, 1, 596); +ok(ledger.publicationLead(true) === Number.POSITIVE_INFINITY, "non-pixel wheel input remains unbounded"); +ledger.endGesture(); +ok(!ledger.commit([]), "an empty measurement batch is a no-op"); +ledger.stage([{ key: "post-viewport", size: 144 }]); +const published = ledger.publishStaged((key) => key === "post-viewport"); +ok(published.length === 1 && published[0]?.key === "post-viewport" && published[0]?.size === 144, + "publication returns the exact immutable suffix snapshot for the range adapter"); +ok(ledger.publishStaged().length === 0, "an already published snapshot is not replayed into TanStack"); + +ok(ledger.commit([ + { key: "turn:1", size: 120 }, + { key: "turn:2", size: 240 }, +]), "a valid measurement batch commits"); +ok(ledger.sizeFor("turn:1", 64) === 120 && ledger.sizeFor("turn:2", 64) === 240, "all measurements become visible in the same snapshot"); + +ok(!ledger.commit([ + { key: "turn:1", size: 120.2 }, + { key: "turn:invalid", size: Number.NaN }, +]), "sub-pixel noise and invalid measurements do not publish a partial snapshot"); +ok(ledger.sizeFor("turn:invalid", 64) === 64, "ignored measurements leave the prior snapshot authoritative"); + +ok(ledger.commit([ + { key: "turn:1", size: 140 }, + { key: "turn:3", size: 360 }, +]), "a later atomic batch replaces every changed key together"); +ok(ledger.sizeFor("turn:1", 64) === 140 && ledger.sizeFor("turn:3", 64) === 360, "the second batch publishes complete contents"); + +ok(ledger.retain(new Set(["turn:1", "turn:3"])), "retaining live block identities removes obsolete measurements"); +ok(ledger.sizeFor("turn:2", 64) === 64, "retention publishes one pruned snapshot"); +ok(!ledger.retain(new Set(["turn:1", "turn:3"])), "retaining an unchanged identity set is a no-op"); + +ok(ledger.stage([ + { key: "turn:before-anchor", size: 180 }, + { key: "turn:after-anchor", size: 220 }, +]), "DOM measurements can be staged before publication"); +ok(ledger.publishStaged((key) => key === "turn:after-anchor").length === 1, "an anchor-safe subset publishes atomically"); +ok(ledger.sizeFor("turn:before-anchor", 64) === 64, "a measurement before the reader anchor remains deferred"); +ok(ledger.sizeFor("turn:after-anchor", 64) === 220, "a measurement after the reader anchor becomes authoritative"); +ok(ledger.publishStaged().length === 1, "an explicit safe boundary publishes the deferred prefix measurement"); +ok(ledger.sizeFor("turn:before-anchor", 64) === 180, "the deferred prefix survives window recycling until publication"); + +ledger.stage([{ key: "turn:before-anchor", size: 400 }]); +ledger.stage([{ key: "turn:before-anchor", size: 180 }]); +ok(ledger.publishStaged().length === 0, "expand then collapse before release discards the superseded staged size"); +ok(ledger.sizeFor("turn:before-anchor", 0) === 180, "collapsed content retains its original measured extent"); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/desktop/frontend/src/__tests__/transcript-question-jump.test.ts b/desktop/frontend/src/__tests__/transcript-question-jump.test.ts new file mode 100644 index 0000000000..bbb77979d0 --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-question-jump.test.ts @@ -0,0 +1,95 @@ +import { TranscriptKernel, type TranscriptKernelClock } from "../lib/transcriptKernel"; +import { TranscriptNavigation } from "../lib/transcriptNavigation"; +import { TranscriptHistoryRequest } from "../lib/transcriptHistoryRequest"; + +let passed = 0; +let failed = 0; +function ok(value: unknown, label: string) { + if (value) { process.stdout.write(` PASS ${label}\n`); passed += 1; } + else { process.stdout.write(` FAIL ${label}\n`); failed += 1; } +} +const frames = new Map(); +let sequence = 0; +const clock: TranscriptKernelClock = { + now: () => 0, + requestAnimationFrame: (callback) => { const id = ++sequence; frames.set(id, callback); return id; }, + cancelAnimationFrame: (id) => { frames.delete(id); }, + setTimeout: () => ++sequence as unknown as ReturnType, + clearTimeout: () => {}, +}; + +console.log("\nTranscript question jump transaction"); +const writes: number[] = []; +const kernel = new TranscriptKernel({ clock }); +kernel.connectWriter((request) => { writes.push(request.offset); return { accepted: true, offset: request.offset, changed: true }; }); +kernel.replaceSurface("one"); +const jump = kernel.stageJumpToBlock("turn:500"); +ok(jump?.status === "active", "an unmounted question starts a transaction while its block is pinned"); +ok(writes.length === 0, "window mounting does not perform an estimated physical write"); +kernel.advanceGeometry(); +ok(Boolean(jump && kernel.correctAnchor(jump, () => 12_120)), "painted target receives its single exact logical-anchor write"); +ok(jump?.status === "committed", "the question jump reaches a terminal state after paint"); +const writesBeforeSwitch = writes.length; +const stale = kernel.stageJumpToBlock("turn:old"); +kernel.replaceSurface("two"); +kernel.advanceGeometry(); +ok(stale?.status === "cancelled", "session switch cancels an old question jump"); +ok(writes.length === writesBeforeSwitch, "the old generation cannot write into the replacement surface"); + +function deferred() { + let resolve!: (value: boolean) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +const navigation = new TranscriptNavigation(kernel); +const history = new TranscriptHistoryRequest(kernel); +const question = { id: "unloaded", text: "", turn: 4 }; +const positioning = navigation.start(question); +const staged = kernel.stageJumpToBlock("turn:unmounted")!; +navigation.locate(positioning, () => {}); +ok(navigation.current?.status === "locating", "navigation remains pending while its target mounts"); +kernel.advanceGeometry(); +kernel.correctAnchor(staged, () => 250); +ok(navigation.current === null && staged.status === "committed", "only the positioned transaction completes navigation"); +const snapshot = { scrollTop: 50, scrollHeight: 1000, clientHeight: 500, visibleBlocks: [] }; +for (const gesture of ["wheel", "touch", "thumb", "selection"] as const) { + const request = navigation.start(question); + const data = deferred(); + const loading = history.load(() => data.promise); + kernel.beginUserGesture(snapshot, gesture === "selection" ? "selection" : "native"); + kernel.endUserGesture(); + data.resolve(true); + ok(await loading, `${gesture}: valid source data may finish loading`); + ok(!navigation.owns(request), `${gesture}: releasing the gesture cannot revive pending navigation`); + navigation.fail(request); + ok(navigation.current === null, `${gesture}: late failure cannot offer a cancelled retry`); +} +kernel.replaceSurface("A"); +const a = navigation.start(question); +const aData = deferred(); +const aLoad = history.load(() => aData.promise); +await Promise.resolve(); +kernel.replaceSurface("B"); +const b = navigation.start({ ...question, id: "B" }); +const bData = deferred(); +let bCalls = 0; +const bLoad = history.load(() => { bCalls += 1; return bData.promise; }); +aData.resolve(false); +await aLoad; +navigation.fail(a); +ok(navigation.current === b && b.status === "pending", "A failure does not alter B UI"); +const sameBLoad = history.load(() => { bCalls += 1; return false; }); +ok(sameBLoad === bLoad && bCalls === 1, "A finally cannot release B's request"); +kernel.replaceSurface("A"); +ok(!navigation.owns(a) && !navigation.owns(b), "A→B→A rejects both old owners"); +const newest = navigation.start(question); +const replaced = navigation.start({ ...question, id: "newer" }); +navigation.fail(newest); +ok(navigation.current === replaced && replaced.status === "pending", "new jump supersedes old failure even at the same turn"); +kernel.detachSurface(); +ok(!navigation.owns(replaced), "unmount revokes navigation ownership"); +bData.resolve(true); +ok(!await bLoad, "B data completion cannot claim the detached surface"); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed) process.exit(1); diff --git a/desktop/frontend/src/__tests__/transcript-timeline-projection.test.ts b/desktop/frontend/src/__tests__/transcript-timeline-projection.test.ts new file mode 100644 index 0000000000..cebd9e09b8 --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-timeline-projection.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { projectTranscriptTimeline, splitWindowedTimeline, defaultTranscriptRenderMode, type TimelineBlock } from "../lib/transcriptTimeline"; +const blocks: TimelineBlock[] = Array.from({ length: 101 }, (_, index) => ({ key: `turn-${index}`, phase: "completed", rows: [], contentRevision: 1, measurementRevision: "1" })); +const active: TimelineBlock = { key: "active", phase: "active", rows: [], contentRevision: 2, measurementRevision: "2" }; +const projection = projectTranscriptTimeline([...blocks, active], true); +assert.deepEqual(projection.completedBlocks, blocks); +assert.equal(projection.activeBlock, active); +assert.equal(projection.hasOlderHistory, true); +assert.equal(defaultTranscriptRenderMode(100), "full"); +assert.equal(defaultTranscriptRenderMode(101), "windowed"); +const split = splitWindowedTimeline(projection); +assert.deepEqual(split.cold, blocks.slice(0, 99)); +assert.deepEqual(split.resident, blocks.slice(99)); +const empty = projectTranscriptTimeline([], false); +assert.deepEqual(splitWindowedTimeline(empty), { cold: [], resident: [] }); +assert.equal(empty.activeBlock, undefined); +assert.equal(empty.hasOlderHistory, false); +assert.equal(blocks.length, 101, "projection cannot mutate source history"); +console.log("Timeline projection: completed/active isolation, paging, threshold and resident boundaries passed"); diff --git a/desktop/frontend/src/__tests__/transcript-window-model.test.ts b/desktop/frontend/src/__tests__/transcript-window-model.test.ts new file mode 100644 index 0000000000..1b54203850 --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-window-model.test.ts @@ -0,0 +1,139 @@ +import { commitTranscriptWindowRange } from "../lib/transcriptWindowRange"; +import { commitTranscriptWindowGeometry } from "../lib/transcriptWindowGeometry"; +import assert from "node:assert/strict"; +function ok(condition: unknown, label: string) { assert.ok(condition, label); console.log(`PASS ${label}`); } +const backing = Array.from({ length: 100 }, (_, index) => ({ key: `block:${index}`, index, start: index * 100, end: (index + 1) * 100, size: 100 })); +const lazyPrefix = new Proxy(new Array<(typeof backing)[number]>(100), { + get: (target, key, receiver) => typeof key === "string" && /^\d+$/.test(key) ? backing[Number(key)] : Reflect.get(target, key, receiver), +}); +const geometryInput = { candidate: backing.slice(5, 20), measurements: lazyPrefix, retainedIndexes: new Set(), + structureRevision: "prefix", scrollTop: 500, clientHeight: 800, scrollMargin: 0, totalSize: 10_000, + maxItems: 38, direction: "forward" as const, gestureActive: true, residentCount: 2, forceFull: false }; +const snapshot = commitTranscriptWindowGeometry(geometryInput); +ok(snapshot.mode === "windowed" && snapshot.prefix.items.length === 100 && snapshot.prefix.items[50].start === 5000, + "lazy TanStack prefix is concretely materialized before geometry ownership"); +backing[50].start = 4990; +ok(snapshot.prefix.items[50].start === 5000, "third-party cache mutation cannot alter a committed prefix snapshot"); +const invalid = commitTranscriptWindowGeometry({ ...geometryInput, previous: snapshot }); +ok(invalid.mode === "full" && invalid.prefix === snapshot.prefix, + "invalid prefix enters covered full presentation using the immutable trusted geometry"); +backing[50].start = 5000; +const previousRange = { + structureRevision: "stable", + scrollTop: 100, + scrollMargin: 0, + totalSize: 20_000, + items: [{ index: 0, start: 50, end: 900 }], + source: "candidate" as const, + covered: true, +}; +const staleCandidate = [{ index: 50, start: 5_000, end: 5_800 }]; +const measurements = Array.from({ length: 200 }, (_, index) => ({ index, start: index * 100, end: (index + 1) * 100 })); +const shrunkBudget = commitTranscriptWindowRange({ + candidate: measurements.slice(0, 38), measurements, retainedIndexes: new Set([0]), + previous: { ...previousRange, items: measurements.slice(0, 38) }, + structureRevision: "stable", scrollTop: 100, clientHeight: 200, + scrollMargin: 0, totalSize: 20_000, maxItems: 5, direction: "forward", gestureActive: true, +}); +ok(shrunkBudget.covered && shrunkBudget.items.length <= 5, + "resident growth prunes stale overscan before judging total mount budget"); +const retained = commitTranscriptWindowRange({ + candidate: staleCandidate, + measurements, + retainedIndexes: new Set(), + previous: previousRange, + structureRevision: "stable", + scrollTop: 180, + clientHeight: 600, + scrollMargin: 0, + totalSize: 20_000, + maxItems: 8, + direction: "forward", + gestureActive: true, +}); +ok(retained.items === previousRange.items, "a stale late range cannot replace native viewport coverage"); +const measuredCandidate = [{ index: 0, start: 40, end: 940 }]; +const measurementOnly = commitTranscriptWindowRange({ + candidate: measuredCandidate, + measurements, + retainedIndexes: new Set(), + previous: previousRange, + structureRevision: "stable", + scrollTop: 100, + clientHeight: 600, + scrollMargin: 0, + totalSize: 20_120, + maxItems: 8, + direction: "forward", + gestureActive: true, +}); +ok(measurementOnly.items === previousRange.items, "a measurement-only range commit stays frozen during native ownership"); +ok(measurementOnly.totalSize === previousRange.totalSize, "a retained range keeps its matching extent snapshot"); +const released = commitTranscriptWindowRange({ + candidate: measuredCandidate, + measurements, + retainedIndexes: new Set(), + previous: measurementOnly, + structureRevision: "stable", + scrollTop: 100, + clientHeight: 600, + scrollMargin: 0, + totalSize: 20_120, + maxItems: 8, + direction: "forward", + gestureActive: false, +}); +ok(released.items !== previousRange.items, "gesture release commits the latest covering measurements"); +ok(released.totalSize === 20_120, "gesture release commits range and extent atomically"); +const reconstructed = commitTranscriptWindowRange({ + candidate: staleCandidate, + measurements, + retainedIndexes: new Set([80]), + structureRevision: "stable", + scrollTop: 1_200, + clientHeight: 600, + scrollMargin: 0, + totalSize: 20_000, + maxItems: 8, + direction: "forward", + gestureActive: true, +}); +ok(reconstructed.source === "reconstructed", "an uncovered native jump reconstructs from the prefix-size ledger"); +ok(reconstructed.items.some((item) => item.start <= 1_200 && item.end >= 1_300), "the reconstructed range covers the native viewport"); +ok(reconstructed.items.some((item) => item.index === 80), "reconstruction retains protected blocks"); +const unavailable = commitTranscriptWindowRange({ + candidate: [], + measurements: [], + retainedIndexes: new Set(), + structureRevision: "unavailable", + scrollTop: 1_200, + clientHeight: 600, + scrollMargin: 0, + totalSize: 20_000, + maxItems: 36, + direction: "forward", + gestureActive: true, +}); +ok(!unavailable.covered && unavailable.source === "unavailable" && unavailable.items.length === 0, + "an unavailable ledger fails closed instead of painting an uncovered candidate"); + +const largeMeasurements = Array.from({ length: 10_000 }, (_, index) => ({ index, start: index * 96, end: (index + 1) * 96 })); +const rangeStartedAt = performance.now(); +const largeRange = commitTranscriptWindowRange({ + candidate: [{ index: 2, start: 192, end: 288 }], + measurements: largeMeasurements, + retainedIndexes: new Set([9_999]), + structureRevision: "10k", + scrollTop: 720_000, + clientHeight: 800, + scrollMargin: 0, + totalSize: 960_000, + maxItems: 38, + direction: "forward", + gestureActive: true, +}); +const rangeElapsedMs = performance.now() - rangeStartedAt; +ok(rangeElapsedMs < 1_000, `10,000-turn range reconstruction completes within 1s (${rangeElapsedMs.toFixed(1)}ms)`); +ok(largeRange.source === "reconstructed" && largeRange.items.length <= 40, "10,000-turn reconstruction keeps a bounded mounted range"); +ok(largeRange.items.some((item) => item.start <= 720_000 && item.end >= 720_096), "10,000-turn reconstruction covers the authoritative viewport"); +ok(largeRange.items.some((item) => item.index === 9_999), "10,000-turn reconstruction preserves protected block identity"); diff --git a/desktop/frontend/src/lib/transcriptGeometryObserver.ts b/desktop/frontend/src/lib/transcriptGeometryObserver.ts new file mode 100644 index 0000000000..700a9a3eb2 --- /dev/null +++ b/desktop/frontend/src/lib/transcriptGeometryObserver.ts @@ -0,0 +1,29 @@ +import type { TranscriptKernel } from "./transcriptKernel"; + +/** Disconnect alone does not revoke already queued ResizeObserver deliveries. */ +export function observeTranscriptGeometry( + kernel: Pick, + element: Element, + before: () => unknown, + commit: () => void, + createObserver: (notify: () => void) => Pick = (notify) => new ResizeObserver(notify), +): () => void { + const generation = kernel.generation; + let disposed = false; + let cancelFrame: (() => void) | null = null; + const current = () => !disposed && generation === kernel.generation; + const observer = createObserver(() => { + if (!current() || cancelFrame) return; + before(); + cancelFrame = kernel.afterCurrentGenerationPaint(() => { + cancelFrame = null; + if (current()) commit(); + }); + }); + observer.observe(element); + return () => { + disposed = true; + observer.disconnect(); + cancelFrame?.(); + }; +} diff --git a/desktop/frontend/src/lib/transcriptHistoryRequest.ts b/desktop/frontend/src/lib/transcriptHistoryRequest.ts new file mode 100644 index 0000000000..b77f5a80ce --- /dev/null +++ b/desktop/frontend/src/lib/transcriptHistoryRequest.ts @@ -0,0 +1,21 @@ +import type { TranscriptKernel } from "./transcriptKernel"; + +/** Source-session data work survives navigation cancellation, but not replacement. */ +export class TranscriptHistoryRequest { + private history: { generation: number; result: Promise } | null = null; + constructor(private readonly kernel: Pick) {} + + load(load: () => boolean | Promise): Promise { + const generation = this.kernel.generation; + if (this.history?.generation === generation) return this.history.result; + const request = { generation, result: Promise.resolve(false) }; + this.history = request; + request.result = Promise.resolve().then(() => generation === this.kernel.generation && load()).then( + (loaded) => loaded && generation === this.kernel.generation, + () => false, + ).finally(() => { + if (this.history === request) this.history = null; + }); + return request.result; + } +} diff --git a/desktop/frontend/src/lib/transcriptKernel.ts b/desktop/frontend/src/lib/transcriptKernel.ts new file mode 100644 index 0000000000..d20c20ea9d --- /dev/null +++ b/desktop/frontend/src/lib/transcriptKernel.ts @@ -0,0 +1,494 @@ +export type ViewportIntent = "tail" | "reader"; + +export type LogicalAnchor = + | { kind: "tail" } + | { kind: "block"; blockKey: string; offsetPx: number }; + +export type ScrollTransactionKind = + | "jump" + | "restore" + | "prepend" + | "display-change" + | "selection" + | "composer-resize" + | "tail-sync"; + +export type ScrollTransaction = { + id: number; + generation: number; + geometryRevision: number; + kind: ScrollTransactionKind; + status: "active" | "committed" | "cancelled" | "expired"; +}; + +export type TranscriptScrollOwner = + | "tail-follow" + | "question-jump" + | "restore" + | "history-prepend" + | "display-change" + | "selection-edge-scroll" + | "composer-resize" + | "custom-scrollbar" + | "nested-scroll" + | "block-window-prepend"; + +export type TranscriptScrollMode = "tail-follow" | "manual" | "selection" | "restoring"; + +export type TranscriptViewportGeometry = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}; + +export type TranscriptVisibleBlock = { + key: string; + top: number; + bottom: number; +}; + +export type TranscriptViewportSnapshot = TranscriptViewportGeometry & { + visibleBlocks: readonly TranscriptVisibleBlock[]; +}; + +export type TranscriptWriteRequest = { + session: string; + generation: number; + transactionId: number; + geometryRevision: number; + owner: TranscriptScrollOwner; + intent: ViewportIntent; + offset: number; +}; + +export type TranscriptWriteResult = { + accepted: boolean; + offset: number; + reason?: string; + changed?: boolean; +}; + +export type TranscriptKernelClock = { + now: () => number; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + cancelAnimationFrame: (handle: number) => void; + setTimeout: (callback: () => void, delay: number) => ReturnType; + clearTimeout: (handle: ReturnType) => void; +}; + +export type TranscriptKernelEvent = { + session: string; + generation: number; + transaction: number; + owner?: TranscriptScrollOwner; + intent: ViewportIntent; + geometryRevision: number; + requestedOffset?: number; + acceptedOffset?: number; + outcome: string; +}; + +type ActiveTransaction = { + listeners?: Set<() => void>; + transaction: ScrollTransaction; + anchor: LogicalAnchor; + correctionRevision: number; + retryUsed: boolean; + timeout: ReturnType; +}; + +type DeferredStructuralTransaction = { + generation: number; + kind: "restore" | "prepend" | "display-change" | "composer-resize"; + anchor: LogicalAnchor; +}; + +const TRANSACTION_TTL_MS = 1_000; +const BOTTOM_THRESHOLD_PX = 4; + +function defaultClock(): TranscriptKernelClock { + return { + now: () => Date.now(), + requestAnimationFrame: (callback) => requestAnimationFrame(callback), + cancelAnimationFrame: (handle) => cancelAnimationFrame(handle), + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (handle) => clearTimeout(handle), + }; +} + +function transactionPriority(kind: ScrollTransactionKind): number { + switch (kind) { + case "selection": return 5; + case "jump": return 4; + case "restore": + case "prepend": + case "display-change": + case "composer-resize": return 3; + case "tail-sync": return 1; + } +} + +export class TranscriptKernel { + private readonly clock: TranscriptKernelClock; + private readonly emit: (event: TranscriptKernelEvent) => void; + private write: ((request: TranscriptWriteRequest) => TranscriptWriteResult) | null = null; + private session = ""; + private generationValue = 0; + private geometryVersion = 0; + private transactionSequence = 0; + private interactionVersion = 0; + private active: ActiveTransaction | null = null; + private deferredStructural: DeferredStructuralTransaction | null = null; + private intentValue: ViewportIntent = "tail"; + private anchorValue: LogicalAnchor = { kind: "tail" }; + private anchors = new Map(); + private userGesture = false; + private tailFrame: number | null = null; + private anomalyCount = 0; + private safeModeValue = false; + private writeTop: number | null = null; + private nativeGestureTimer: ReturnType | null = null; + + constructor(options: { clock?: TranscriptKernelClock; emit?: (event: TranscriptKernelEvent) => void } = {}) { + this.clock = options.clock ?? defaultClock(); + this.emit = options.emit ?? (() => {}); + } + + get generation(): number { return this.generationValue; } + get interactionRevision(): number { return this.interactionVersion; } + get geometryRevision(): number { return this.geometryVersion; } + get intent(): ViewportIntent { return this.intentValue; } + get anchor(): LogicalAnchor { return this.anchorValue; } + get safeMode(): boolean { return this.safeModeValue; } + get userGestureActive(): boolean { return this.userGesture; } + get nativeGestureLeaseActive(): boolean { return this.nativeGestureTimer !== null; } + get activeTransaction(): ScrollTransaction | null { return this.active?.transaction ?? null; } + + connectWriter(writer: (request: TranscriptWriteRequest) => TranscriptWriteResult): () => void { + this.write = writer; + return () => { + if (this.write === writer) this.write = null; + }; + } + + detachSurface(): void { + this.clearNativeGestureLease(); + this.cancelActive("surface-detached"); + this.deferredStructural = null; + if (this.tailFrame !== null) this.clock.cancelAnimationFrame(this.tailFrame); + this.tailFrame = null; + this.generationValue += 1; + this.userGesture = false; + this.writeTop = null; + } + + replaceSurface(session: string): { generation: number; anchor: LogicalAnchor } { + if (this.session) this.anchors.set(this.session, this.anchorValue); + this.clearNativeGestureLease(); + this.cancelActive("surface-replaced"); + this.deferredStructural = null; + if (this.tailFrame !== null) this.clock.cancelAnimationFrame(this.tailFrame); + this.tailFrame = null; + this.session = session; + this.generationValue += 1; + this.geometryVersion = 0; + this.userGesture = false; + this.writeTop = null; + this.anomalyCount = 0; + this.safeModeValue = false; + this.anchorValue = this.anchors.get(session) ?? { kind: "tail" }; + this.intentValue = this.anchorValue.kind === "tail" ? "tail" : "reader"; + return { generation: this.generationValue, anchor: this.anchorValue }; + } + + advanceGeometry(generation = this.generationValue): number { + if (generation !== this.generationValue) return this.geometryVersion; + this.geometryVersion += 1; + return this.geometryVersion; + } + + capture(snapshot: TranscriptViewportSnapshot): LogicalAnchor { + if (this.intentValue === "tail") return { kind: "tail" }; + const first = snapshot.visibleBlocks.find((block) => block.bottom > snapshot.scrollTop + 0.5); + if (!first) return this.anchorValue; + return { kind: "block", blockKey: first.key, offsetPx: snapshot.scrollTop - first.top }; + } + + observeNativeScroll( + snapshot: TranscriptViewportSnapshot, + nativeEvent = true, + ): boolean { + if (nativeEvent) { + const writerTop = this.writeTop; + this.writeTop = null; + if (writerTop !== null && Math.abs(snapshot.scrollTop - writerTop) <= BOTTOM_THRESHOLD_PX) return false; + } + if (nativeEvent && !this.userGesture && this.active) return false; + const atBottom = snapshot.scrollHeight - snapshot.clientHeight - snapshot.scrollTop <= BOTTOM_THRESHOLD_PX; + this.intentValue = atBottom ? "tail" : "reader"; + this.anchorValue = this.intentValue === "tail" ? { kind: "tail" } : this.capture(snapshot); + this.anchors.set(this.session, this.anchorValue); + return nativeEvent; + } + + beginUserGesture(snapshot: TranscriptViewportSnapshot, owner: "selection" | "native" = "native"): void { + this.interactionVersion += 1; + this.clearNativeGestureLease(); + this.cancelTailFrame(); + this.userGesture = true; + this.intentValue = "reader"; + this.anchorValue = this.capture(snapshot); + this.anchors.set(this.session, this.anchorValue); + if (owner === "selection") this.begin("selection", this.anchorValue); + else this.cancelActive("user-gesture"); + } + + endUserGesture(): ScrollTransaction | null { + this.clearNativeGestureLease(); + this.userGesture = false; + if (this.active?.transaction.kind === "selection") this.finish(this.active.transaction.id, "committed", "selection-ended"); + const deferred = this.deferredStructural; + this.deferredStructural = null; + if (!deferred || deferred.generation !== this.generationValue) return null; + return this.begin(deferred.kind, deferred.anchor); + } + + renewNativeGesture( + snapshot: TranscriptViewportSnapshot, + idleMs: number, + onEnd: (resumed: ScrollTransaction | null) => void, + ): void { + this.clearNativeGestureLease(); + if (this.userGesture) this.observeNativeScroll(snapshot); + else this.beginUserGesture(snapshot, "native"); + const generation = this.generationValue; + const timer = this.clock.setTimeout(() => { + if (generation !== this.generationValue || this.nativeGestureTimer !== timer) return; + this.nativeGestureTimer = null; + onEnd(this.endUserGesture()); + }, Math.max(0, idleMs)); + this.nativeGestureTimer = timer; + } + + afterCurrentGenerationPaint(callback: () => void): () => void { + const generation = this.generationValue; + let cancelled = false; + const handle = this.clock.requestAnimationFrame(() => { + if (!cancelled && generation === this.generationValue) callback(); + }); + return () => { cancelled = true; this.clock.cancelAnimationFrame(handle); }; + } + + begin(kind: ScrollTransactionKind, anchor = this.anchorValue): ScrollTransaction | null { + if (this.userGesture && kind !== "selection") { + if (kind === "restore" || kind === "prepend" || kind === "display-change" || kind === "composer-resize") { + this.deferredStructural = { generation: this.generationValue, kind, anchor }; + } + return null; + } + if (this.active && transactionPriority(this.active.transaction.kind) > transactionPriority(kind)) return null; + if (kind !== "tail-sync") this.cancelTailFrame(); + this.cancelActive("superseded"); + const transaction: ScrollTransaction = { + id: ++this.transactionSequence, + generation: this.generationValue, + geometryRevision: this.geometryVersion, + kind, + status: "active", + }; + const timeout = this.clock.setTimeout(() => this.finish(transaction.id, "expired", "deadline"), TRANSACTION_TTL_MS); + this.active = { transaction, anchor, correctionRevision: -1, retryUsed: false, timeout }; + this.anchorValue = anchor; + this.intentValue = anchor.kind === "tail" ? "tail" : "reader"; + this.anchors.set(this.session, anchor); + this.emitEvent(transaction, undefined, undefined, undefined, "active"); + return transaction; + } + + cancelActive(outcome = "cancelled"): void { + if (this.active) this.finish(this.active.transaction.id, "cancelled", outcome); + } + + onTransactionEnd(transaction: ScrollTransaction, listener: () => void): void { + if (this.active?.transaction !== transaction) { listener(); return; } + (this.active.listeners ??= new Set()).add(listener); + } + + finish(id: number, status: Exclude, outcome: string = status): boolean { + const active = this.active; + if (!active || active.transaction.id !== id) return false; + this.clock.clearTimeout(active.timeout); + active.transaction.status = status; + this.emitEvent(active.transaction, undefined, undefined, undefined, outcome); + this.active = null; + active.listeners?.forEach((listener) => listener()); + return true; + } + + correctAnchor(transaction: ScrollTransaction, blockTop: (blockKey: string) => number | undefined): boolean { + const active = this.active; + if (!active || active.transaction.id !== transaction.id || transaction.generation !== this.generationValue) return false; + if (this.userGesture || active.transaction.kind === "selection") return false; + if (active.correctionRevision === this.geometryVersion) return false; + active.correctionRevision = this.geometryVersion; + if (active.anchor.kind === "tail") return this.writeAndFinish(active, "tail-follow", Number.POSITIVE_INFINITY); + const top = blockTop(active.anchor.blockKey); + if (top == null || !Number.isFinite(top)) { + if (!active.retryUsed) { + active.retryUsed = true; + return false; + } + this.reportAnomaly("missing-anchor"); + this.finish(transaction.id, "cancelled", "anchor-missing"); + return false; + } + const owner: TranscriptScrollOwner = active.transaction.kind === "jump" ? "question-jump" + : active.transaction.kind === "prepend" ? "history-prepend" + : active.transaction.kind === "display-change" ? "display-change" + : active.transaction.kind === "composer-resize" ? "composer-resize" + : "restore"; + return this.writeAndFinish(active, owner, top + active.anchor.offsetPx); + } + + jumpToBlock(blockKey: string, blockTop: (blockKey: string) => number | undefined): boolean { + const transaction = this.begin("jump", { kind: "block", blockKey, offsetPx: 0 }); + return transaction ? this.correctAnchor(transaction, blockTop) : false; + } + + stageJumpToBlock(blockKey: string): ScrollTransaction | null { + return this.begin("jump", { kind: "block", blockKey, offsetPx: 0 }); + } + + scrollToTail(): boolean { + this.interactionVersion += 1; + this.intentValue = "tail"; + this.anchorValue = { kind: "tail" }; + this.anchors.set(this.session, this.anchorValue); + const transaction = this.begin("tail-sync", this.anchorValue); + return Boolean(transaction && this.active && this.writeAndFinish(this.active, "tail-follow", Number.POSITIVE_INFINITY)); + } + + scheduleTailSync(): void { + if (this.intentValue !== "tail" || this.userGesture || this.tailFrame !== null) return; + const generation = this.generationValue; + this.tailFrame = this.clock.requestAnimationFrame(() => { + this.tailFrame = null; + if (generation !== this.generationValue || this.intentValue !== "tail" || this.userGesture) return; + const transaction = this.begin("tail-sync", { kind: "tail" }); + if (!transaction || !this.active) return; + this.writeAndFinish(this.active, "tail-follow", Number.POSITIVE_INFINITY); + }); + } + + writeUserControlled(owner: "selection-edge-scroll" | "custom-scrollbar" | "nested-scroll", offset: number): boolean { + if (!this.write) return false; + let active = this.active; + if (!active || active.transaction.kind !== "selection") { + const transaction = this.begin("selection", this.anchorValue); + active = transaction ? this.active : null; + } + if (!active) return false; + const result = this.write({ + session: this.session, + generation: this.generationValue, + transactionId: active.transaction.id, + geometryRevision: this.geometryVersion, + owner, + intent: "reader", + offset, + }); + this.emitEvent(active.transaction, owner, offset, result.offset, result.accepted ? "accepted" : result.reason ?? "rejected"); + return result.accepted; + } + + writeStructuralOffset(owner: "block-window-prepend", offset: number): boolean { + if (!this.write || this.userGesture) return false; + const transaction = this.begin("prepend", this.anchorValue); + if (!transaction || !this.active) return false; + const result = this.write({ + session: this.session, + generation: this.generationValue, + transactionId: transaction.id, + geometryRevision: this.geometryVersion, + owner, + intent: this.intentValue, + offset, + }); + this.emitEvent(transaction, owner, offset, result.offset, result.accepted ? "accepted" : result.reason ?? "rejected"); + if (result.accepted) { + this.writeTop = result.changed ? result.offset : null; + this.finish(transaction.id, "committed", "committed"); + } + return result.accepted; + } + + reportAnomaly(outcome: "blank-viewport" | "invalid-geometry" | "missing-anchor"): void { + this.anomalyCount += 1; + this.emit({ + session: this.session, + generation: this.generationValue, + transaction: this.active?.transaction.id ?? 0, + intent: this.intentValue, + geometryRevision: this.geometryVersion, + outcome, + }); + if (this.anomalyCount >= 2) { + this.safeModeValue = true; + this.cancelActive("safe-mode"); + } + } + + reportHealthyGeometry(): void { + this.anomalyCount = 0; + } + + private writeAndFinish(active: ActiveTransaction, owner: TranscriptScrollOwner, requested: number): boolean { + if (!this.write || active.transaction.generation !== this.generationValue || this.userGesture) return false; + const result = this.write({ + session: this.session, + generation: this.generationValue, + transactionId: active.transaction.id, + geometryRevision: this.geometryVersion, + owner, + intent: this.intentValue, + offset: requested, + }); + this.emitEvent(active.transaction, owner, requested, result.offset, result.accepted ? "accepted" : result.reason ?? "rejected"); + if (result.accepted) { + this.writeTop = result.changed ? result.offset : null; + this.finish(active.transaction.id, "committed", "committed"); + } + return result.accepted; + } + + private cancelTailFrame(): void { + if (this.tailFrame === null) return; + this.clock.cancelAnimationFrame(this.tailFrame); + this.tailFrame = null; + } + + private clearNativeGestureLease(): void { + if (this.nativeGestureTimer !== null) this.clock.clearTimeout(this.nativeGestureTimer); + this.nativeGestureTimer = null; + } + + private emitEvent( + transaction: ScrollTransaction, + owner?: TranscriptScrollOwner, + requestedOffset?: number, + acceptedOffset?: number, + outcome: string = transaction.status, + ): void { + this.emit({ + session: this.session, + generation: transaction.generation, + transaction: transaction.id, + owner, + intent: this.intentValue, + geometryRevision: this.geometryVersion, + requestedOffset, + acceptedOffset, + outcome, + }); + } +} diff --git a/desktop/frontend/src/lib/transcriptMeasurementLedger.ts b/desktop/frontend/src/lib/transcriptMeasurementLedger.ts new file mode 100644 index 0000000000..d809ea7dc2 --- /dev/null +++ b/desktop/frontend/src/lib/transcriptMeasurementLedger.ts @@ -0,0 +1,111 @@ +export type TranscriptMeasurementChange = { + key: string; + size: number; +}; + +/** + * Immutable, block-keyed DOM measurement snapshots for the Transcript window. + * A render can observe either the old snapshot or the complete new snapshot, + * never the partially-updated prefix tree produced by per-item publication. + */ +export class TranscriptMeasurementLedger { + private sizes: ReadonlyMap = new Map(); + private staged = new Map(); + private wheelLeadPx = 0; + private viewportReservePx = 0; + private observedScrollTop: number | undefined; + + observeWheel(deltaY: number, deltaMode: number, clientHeight: number): void { + if (this.wheelLeadPx === 0) this.viewportReservePx = clientHeight; + this.wheelLeadPx = deltaMode === 0 + ? this.wheelLeadPx + Math.abs(deltaY) + (this.wheelLeadPx === 0 ? this.viewportReservePx : 0) + : Number.POSITIVE_INFINITY; + } + + observeViewport(scrollTop: number): void { + if (!Number.isFinite(scrollTop)) return; + if (this.observedScrollTop != null && this.wheelLeadPx > 0 && Number.isFinite(this.wheelLeadPx)) { + // Only physical progress retires queued compositor travel. Keep one + // viewport of runway; a long gesture must not freeze all future rows. + this.wheelLeadPx = Math.max(this.viewportReservePx, + this.wheelLeadPx - Math.abs(scrollTop - this.observedScrollTop)); + } + this.observedScrollTop = scrollTop; + } + + beginUnboundedGesture(): void { + this.wheelLeadPx = Number.POSITIVE_INFINITY; + } + + publicationLead(gestureActive: boolean): number { + // Native capture is the immediate authority. React may publish the + // kernel's gesture snapshot one commit later (notably on WebKitGTK), so a + // native lease must protect its boundary before React commits it. + return gestureActive || this.wheelLeadPx > 0 + ? this.wheelLeadPx || Number.POSITIVE_INFINITY + : 0; + } + + endGesture(): void { + this.wheelLeadPx = 0; + this.viewportReservePx = 0; + } + + sizeFor(key: string, fallback: number): number { + return this.sizes.get(key) ?? fallback; + } + + commit(changes: readonly TranscriptMeasurementChange[]): boolean { + if (changes.length === 0) return false; + const next = new Map(this.sizes); + let changed = false; + for (const change of changes) { + if (!change.key || !Number.isFinite(change.size) || change.size <= 0) continue; + if (Math.abs((next.get(change.key) ?? 0) - change.size) <= 0.5) { + this.staged.delete(change.key); + continue; + } + next.set(change.key, change.size); + this.staged.delete(change.key); + changed = true; + } + if (!changed) return false; + this.sizes = next; + return true; + } + + stage(changes: readonly TranscriptMeasurementChange[]): boolean { + let changed = false; + for (const change of changes) { + if (!change.key || !Number.isFinite(change.size) || change.size <= 0) continue; + const current = this.staged.get(change.key) ?? this.sizes.get(change.key) ?? 0; + if (Math.abs(current - change.size) <= 0.5) continue; + this.staged.set(change.key, change.size); + changed = true; + } + return changed; + } + + publishStaged(canPublish: (key: string) => boolean = () => true): readonly TranscriptMeasurementChange[] { + const publishable: TranscriptMeasurementChange[] = []; + for (const [key, size] of this.staged) { + if (canPublish(key)) publishable.push({ key, size }); + } + if (!this.commit(publishable)) return []; + return publishable; + } + + retain(keys: ReadonlySet): boolean { + for (const key of this.staged.keys()) { + if (!keys.has(key)) this.staged.delete(key); + } + if (this.sizes.size === 0) return false; + const next = new Map(); + for (const [key, size] of this.sizes) { + if (keys.has(key)) next.set(key, size); + } + if (next.size === this.sizes.size) return false; + this.sizes = next; + return true; + } +} diff --git a/desktop/frontend/src/lib/transcriptNavigation.ts b/desktop/frontend/src/lib/transcriptNavigation.ts new file mode 100644 index 0000000000..80b046d51d --- /dev/null +++ b/desktop/frontend/src/lib/transcriptNavigation.ts @@ -0,0 +1,54 @@ +import type { TranscriptKernel } from "./transcriptKernel"; +import type { QuestionAnchor } from "./transcriptGrouping"; + +export type QuestionNavigation = { + question: QuestionAnchor; + generation: number; + interaction: number; + status: "pending" | "locating" | "failed"; + attemptedPage?: unknown; +}; + +/** An interaction owns permission to navigate, never the source session's data work. */ +export class TranscriptNavigation { + private navigation: QuestionNavigation | null = null; + + constructor(private readonly kernel: TranscriptKernel) {} + + owns(request: QuestionNavigation | null): request is QuestionNavigation { + return request !== null && request === this.navigation + && request.generation === this.kernel.generation + && request.interaction === this.kernel.interactionRevision; + } + + get current(): QuestionNavigation | null { + return this.owns(this.navigation) ? this.navigation : null; + } + + start(question: QuestionAnchor): QuestionNavigation { + this.kernel.cancelActive("question-replaced"); + return this.navigation = { question, generation: this.kernel.generation, + interaction: this.kernel.interactionRevision, status: "pending" }; + } + + complete(request: QuestionNavigation): void { + if (this.owns(request)) this.navigation = null; + } + + fail(request: QuestionNavigation): void { + if (this.owns(request)) request.status = "failed"; + } + + locate(request: QuestionNavigation, notify: () => void): void { + const transaction = this.kernel.activeTransaction; + if (!transaction || transaction.kind !== "jump") { this.complete(request); return; } + request.status = "locating"; + this.kernel.onTransactionEnd(transaction, () => { + if (!this.owns(request)) return; + if (transaction.status === "expired") this.fail(request); + else this.complete(request); + notify(); + }); + } + +} diff --git a/desktop/frontend/src/lib/transcriptTimeline.ts b/desktop/frontend/src/lib/transcriptTimeline.ts new file mode 100644 index 0000000000..83ef77c0f5 --- /dev/null +++ b/desktop/frontend/src/lib/transcriptTimeline.ts @@ -0,0 +1,73 @@ +import type { TranscriptRowWithLayout } from "./transcriptRows"; + +export type TimelineBlock = { + key: string; + turn?: number; + phase: "completed" | "active"; + rows: readonly TranscriptRowWithLayout[]; + contentRevision: number; + measurementRevision: string; + questionAnchor?: string; +}; + +export type TimelineProjection = { + completedBlocks: readonly TimelineBlock[]; + activeBlock?: TimelineBlock; + hasOlderHistory: boolean; +}; + +export type TranscriptRenderMode = "full" | "windowed"; + +export const TRANSCRIPT_WINDOW_THRESHOLD_TURNS = 100; +export const TRANSCRIPT_RESIDENT_COMPLETED_TURNS = 2; + +export function projectTranscriptTimeline( + blocks: readonly TimelineBlock[], + hasOlderHistory: boolean, +): TimelineProjection { + let activeBlock: TimelineBlock | undefined; + for (let index = blocks.length - 1; index >= 0; index -= 1) { + if (blocks[index].phase === "active") { + activeBlock = blocks[index]; + break; + } + } + return { + completedBlocks: blocks.filter((block) => block.phase === "completed"), + activeBlock, + hasOlderHistory, + }; +} + +export function defaultTranscriptRenderMode(completedTurns: number): TranscriptRenderMode { + return completedTurns > TRANSCRIPT_WINDOW_THRESHOLD_TURNS ? "windowed" : "full"; +} + +function diagnosticsOverrideAllowed(): boolean { + const channel = typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "development"; + return channel === "test" || channel === "preview" || channel === "canary" || Boolean(import.meta.env?.DEV); +} + +export function transcriptRenderMode( + completedTurns: number, + safeMode: boolean, + search = typeof window === "undefined" ? "" : window.location.search, +): TranscriptRenderMode { + if (safeMode) return "full"; + if (diagnosticsOverrideAllowed()) { + const requested = new URLSearchParams(search).get("transcriptRenderMode"); + if (requested === "full" || requested === "windowed") return requested; + } + return defaultTranscriptRenderMode(completedTurns); +} + +export function splitWindowedTimeline(projection: TimelineProjection): { + cold: readonly TimelineBlock[]; + resident: readonly TimelineBlock[]; +} { + const split = Math.max(0, projection.completedBlocks.length - TRANSCRIPT_RESIDENT_COMPLETED_TURNS); + return { + cold: projection.completedBlocks.slice(0, split), + resident: projection.completedBlocks.slice(split), + }; +} diff --git a/desktop/frontend/src/lib/transcriptWindowGeometry.ts b/desktop/frontend/src/lib/transcriptWindowGeometry.ts new file mode 100644 index 0000000000..74028b7867 --- /dev/null +++ b/desktop/frontend/src/lib/transcriptWindowGeometry.ts @@ -0,0 +1,38 @@ +import { commitTranscriptWindowRange, type TranscriptWindowItem, type TranscriptWindowRange } from "./transcriptWindowRange"; + +type PrefixItem = TranscriptWindowItem & { key: string | number | bigint; size: number }; +export const MAX_MOUNTED_COMPLETED_BLOCKS = 40; +export type TranscriptWindowGeometry = { + range: TranscriptWindowRange; + prefix: { items: readonly T[]; extent: number; margin: number }; + covered: boolean; + mode: "full" | "windowed"; +}; + +/** Own range, prefix, and extent together; third-party cache views are not snapshots. */ +export function commitTranscriptWindowGeometry( + input: Omit>[0], "previous"> & { + previous?: TranscriptWindowGeometry; + residentCount: number; + forceFull: boolean; + scrollHeight?: number; + }, +): TranscriptWindowGeometry { + // TanStack's single-lane view is a lazy Proxy backed by a mutable typed + // array. map/every can skip its virtual indices; materialize before owning it. + const items = Array.from(input.measurements, (item) => ({ ...item })); + const valid = Number.isFinite(input.totalSize) && input.totalSize >= 0 + && (input.totalSize === 0 || items.length > 0) + && items.every((item, index) => Number.isFinite(item.start) && Number.isFinite(item.end) + && Number.isFinite(item.size) && item.size > 0 && Math.abs(item.end - item.start - item.size) <= 0.5 + && Math.abs(item.start - (items[index - 1]?.end ?? input.scrollMargin)) <= 0.5) + && Math.abs((items[items.length - 1]?.end ?? input.scrollMargin) - input.scrollMargin - input.totalSize) <= 0.5; + const previous = input.previous; + let prefix = valid ? { items, extent: input.totalSize, margin: input.scrollMargin } + : previous?.range.structureRevision === input.structureRevision ? previous.prefix : { items: [], extent: 0, margin: 0 }; + const range = commitTranscriptWindowRange({ ...input, measurements: items, previous: previous?.range }); + if (range.source === "retained" && previous) prefix = previous.prefix; + const covered = valid && Number.isFinite(input.scrollHeight ?? 0) && range.covered + && range.items.length + input.residentCount <= MAX_MOUNTED_COMPLETED_BLOCKS; + return { range, prefix, covered, mode: input.forceFull || !covered ? "full" : "windowed" }; +} diff --git a/desktop/frontend/src/lib/transcriptWindowRange.ts b/desktop/frontend/src/lib/transcriptWindowRange.ts new file mode 100644 index 0000000000..a1309607e0 --- /dev/null +++ b/desktop/frontend/src/lib/transcriptWindowRange.ts @@ -0,0 +1,190 @@ +export type TranscriptWindowItem = { + index: number; + start: number; + end: number; +}; + +export type TranscriptWindowRangeSource = "candidate" | "retained" | "reconstructed" | "unavailable"; +export type TranscriptWindowDirection = "forward" | "backward" | null; + +export function extractTranscriptWindowIndexes( + range: { startIndex: number; endIndex: number; count: number }, + retainedIndexes: ReadonlySet, + maxItems: number, + direction: TranscriptWindowDirection, +): number[] { + const indexes = new Set(); + for (let index = range.startIndex; index <= range.endIndex; index += 1) indexes.add(index); + retainedIndexes.forEach((index) => { + if (index >= 0 && index < range.count) indexes.add(index); + }); + const limit = Math.max(maxItems, indexes.size); + const addBefore = (count = Number.POSITIVE_INFINITY) => { + let added = 0; + for (let index = range.startIndex - 1; index >= 0 && indexes.size < limit && added < count; index -= 1) { + const size = indexes.size; + indexes.add(index); + if (indexes.size > size) added += 1; + } + }; + const addAfter = (count = Number.POSITIVE_INFINITY) => { + let added = 0; + for (let index = range.endIndex + 1; index < range.count && indexes.size < limit && added < count; index += 1) { + const size = indexes.size; + indexes.add(index); + if (indexes.size > size) added += 1; + } + }; + const reverseRunway = 4; + if (direction === "forward") { + addBefore(reverseRunway); + addAfter(); + addBefore(); + } else if (direction === "backward") { + addAfter(reverseRunway); + addBefore(); + addAfter(); + } else { + for (let offset = 1; indexes.size < limit && (range.startIndex - offset >= 0 || range.endIndex + offset < range.count); offset += 1) { + if (range.startIndex - offset >= 0) indexes.add(range.startIndex - offset); + if (indexes.size < limit && range.endIndex + offset < range.count) indexes.add(range.endIndex + offset); + } + } + return Array.from(indexes).sort((left, right) => left - right); +} + +export type TranscriptWindowRange = { + structureRevision: string; + scrollTop: number; + scrollMargin: number; + totalSize: number; + items: readonly T[]; + source: TranscriptWindowRangeSource; + covered: boolean; +}; + +function coversColdViewport( + items: readonly T[], + scrollTop: number, + clientHeight: number, + coldStart: number, + coldEnd: number, +): boolean { + if (![scrollTop, clientHeight, coldStart, coldEnd].every(Number.isFinite) || coldEnd < coldStart) return false; + const start = Math.max(scrollTop, coldStart); + const end = Math.min(scrollTop + clientHeight, coldEnd); + if (end <= start) return true; + let cursor = start; + for (const item of [...items].sort((left, right) => left.start - right.start)) { + if (item.end <= cursor) continue; + if (item.start > cursor + 0.5) return false; + cursor = Math.max(cursor, item.end); + if (cursor >= end - 0.5) return true; + } + return false; +} + +function reconstructRange( + measurements: readonly T[], + retainedIndexes: ReadonlySet, + scrollTop: number, + clientHeight: number, + coldStart: number, + coldEnd: number, + maxItems: number, + direction: TranscriptWindowDirection, +): readonly T[] { + const start = Math.max(scrollTop, coldStart); + const end = Math.min(scrollTop + clientHeight, coldEnd); + if (end <= start) return measurements.filter((item) => retainedIndexes.has(item.index)); + const first = measurements.findIndex((item) => item.end > start); + if (first < 0) return []; + let last = first; + while (last + 1 < measurements.length && measurements[last + 1].start < end) last += 1; + return extractTranscriptWindowIndexes({ startIndex: first, endIndex: last, count: measurements.length }, retainedIndexes, maxItems, direction) + .map((index) => measurements[index]) + .filter((item): item is T => Boolean(item)); +} + +export function commitTranscriptWindowRange({ + candidate, + measurements, + retainedIndexes, + previous, + structureRevision, + scrollTop, + clientHeight, + scrollMargin, + totalSize, + maxItems, + direction, + gestureActive, +}: { + candidate: readonly T[]; + measurements: readonly T[]; + retainedIndexes: ReadonlySet; + previous?: TranscriptWindowRange; + structureRevision: string; + scrollTop: number; + clientHeight: number; + scrollMargin: number; + totalSize: number; + maxItems: number; + direction: TranscriptWindowDirection; + gestureActive: boolean; +}): TranscriptWindowRange { + const coldStart = scrollMargin; + const coldEnd = scrollMargin + totalSize; + const next: TranscriptWindowRange = { structureRevision, scrollTop, scrollMargin, totalSize, items: candidate, source: "candidate", covered: false }; + // Overscan is optional. Re-budget it against today's resident/protected set + // before accepting either a new candidate or an immutable prior snapshot. + const fit = (items: readonly T[], start: number, end: number): readonly T[] => { + if (items.length <= maxItems) return items; + const required = items.filter((item) => retainedIndexes.has(item.index) + || (item.end > Math.max(scrollTop, start) && item.start < Math.min(scrollTop + clientHeight, end))); + const keys = new Set(required.map((item) => item.index)); + const optional = items.filter((item) => !keys.has(item.index)) + .sort((a, b) => Math.abs(a.start - scrollTop) - Math.abs(b.start - scrollTop)); + return [...required, ...optional.slice(0, Math.max(0, maxItems - required.length))].sort((a, b) => a.index - b.index); + }; + const usable = (items: readonly T[], start: number, end: number) => items.length <= maxItems + && [...retainedIndexes].every((index) => items.some((item) => item.index === index)) + && coversColdViewport(items, scrollTop, clientHeight, start, end); + const fittedCandidate = fit(candidate, coldStart, coldEnd); + const fittedPrevious = previous && fit(previous.items, previous.scrollMargin, previous.scrollMargin + previous.totalSize); + const sameStructure = previous?.structureRevision === structureRevision; + const sameMargin = previous != null && Math.abs(previous.scrollMargin - scrollMargin) <= 0.5; + const previousCovers = Boolean(sameStructure && sameMargin && fittedPrevious && usable( + fittedPrevious, + previous.scrollMargin, + previous.scrollMargin + previous.totalSize, + )); + const candidateCovers = usable(fittedCandidate, coldStart, coldEnd); + + // A measurement-only notification must not move the painted reader range + // while native input still owns the unchanged viewport. + if (previous && previousCovers && gestureActive && Math.abs(previous.scrollTop - scrollTop) <= 0.5) { + return { ...previous, items: fittedPrevious!, source: "retained", covered: true }; + } + if (candidateCovers) return { ...next, items: fittedCandidate, covered: true }; + + // Native WebViews may deliver a stale range notification after a newer + // scroll position was already painted. Retain the last covering range until + // TanStack produces a candidate that covers the authoritative native view. + if (previous && previousCovers) { + return { ...previous, items: fittedPrevious!, scrollTop, source: "retained", covered: true }; + } + + // A large native jump can invalidate both the candidate and the previously + // painted range. Rebuild synchronously from TanStack's prefix-size ledger so + // the adapter never commits an uncovered viewport while waiting for its next + // asynchronous range notification. + const reconstructed = reconstructRange(measurements, retainedIndexes, scrollTop, clientHeight, coldStart, coldEnd, maxItems, direction); + if (usable(reconstructed, coldStart, coldEnd)) { + return { structureRevision, scrollTop, scrollMargin, totalSize, items: reconstructed, source: "reconstructed", covered: true }; + } + // Never paint a range that leaves the authoritative native viewport + // uncovered. The adapter renders the same projection through its full-DOM + // safety path until a covering immutable range is available. + return { ...next, items: [], source: "unavailable", covered: false }; +}