From cd811e18ac48d8c19f6fdf2073fe278d0fc70995 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 00:38:59 +0800 Subject: [PATCH 1/4] refactor(desktop): give the Renderer the transcript window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main owned the presentation window: a resident range with reading anchors, navigation versions, overlay settlement across pages and eviction bookkeeping, while the Renderer kept its own scroll state on top. Two owners of one window meant every reader gesture met Main's range accounting, and the reading position existed in six representations that had to agree. Main now keeps a tail cache and answers page requests pass-through. `loadBefore` / `loadAfter` return a page without touching the cache, `loadAround` and `loadLatest` return a reset snapshot, and catch-up evicts whole Turns from the oldest edge while always keeping the newest. The Renderer owns the window: it extends it by pixel bandwidth, trims it with `retain`, and re-opens the edge it trimmed as a gap. `hasOlder` / `hasNewer` are Host page cursors on an answer; the Renderer combines them with its own coverage, so paging reaching an end never means nothing exists outside the window. `navigationVersion` invalidates navigation only — Session id, replica generation and Host epoch stay independent identity checks. Overlay settlement follows the same rule. Main used to broadcast `completedOverlayMessageIds`, so a window retired an overlay whether or not it installed the durable row that replaced it. A window off the tail declines tail growth, so it deleted the overlay and dropped the body in the same batch and the Turn vanished from the reader's view. The catch-up broadcast also filtered its rows through Main's own tail residency, so a row installed and immediately evicted under budget never reached any window at all. Retirement is now the window's own inference — installing a durable row retires the overlay it settles — and the broadcast carries every row the catch-up read. The reading position is one representation: the authority publishes the Turn crossing the top of the scrollport, the prompt rail derives its tick from that instead of running its own observers, and the controller keeps a bookmark to re-anchor after a replica generation change. Behavior changes: opening a Session prefetches history until two viewports sit above the reader; tail growth marks read while any consumer is open, where it previously waited for the reader to have nothing newer. Supersedes #5147, which patched these symptoms at the old window authority. Closes #5163 Generated-by: Claude Code --- .../e2e/partial-history-notice.spec.ts | 20 +- .../e2e/transcript-scroll-cost.spec.ts | 58 +- .../app-shell-session-ui-state.test.ts | 246 +----- .../desktop-transcript-range-store.test.ts | 727 +----------------- ...me-host-session-execution-ipc-main.test.ts | 22 +- .../runtime-host-session-observer.test.ts | 145 +++- .../__tests__/transcript-identity.test.ts | 5 +- .../transcript-navigation-pager.test.ts | 34 +- .../transcript-navigation-race.test.ts | 210 ++--- .../transcript-navigation-regression.test.ts | 102 +-- .../transcript-overlay-settlement.test.ts | 334 +++----- ...script-reading-position-controller.test.ts | 270 ++----- .../transcript-send-viewport.test.ts | 13 +- ...ub-coordination-transcript-preload.test.ts | 17 +- .../src/main/desktop-transcript-ipc.ts | 76 +- .../src/main/desktop-transcript-replica.ts | 645 ++++------------ ...runtime-host-session-execution-ipc-main.ts | 19 +- ...ntime-host-session-observation-registry.ts | 94 +-- .../src/main/runtime-host-session-observer.ts | 233 +++--- apps/desktop/src/preload/preload.ts | 27 +- .../src/preload/transcript-contract.ts | 43 +- .../desktop/src/renderer/app-shell-effects.ts | 6 +- apps/desktop/src/renderer/app-shell.tsx | 5 +- .../src/renderer/chat-message-surface.tsx | 9 +- ...transcript-reading-position-controller.tsx | 101 +-- .../controller/transcript-reading-position.ts | 104 +-- .../renderer/features/conversation/testing.ts | 2 - .../desktop/desktop-transcript-range-store.ts | 266 ++++--- .../src/renderer/styles/chat-message.css | 10 + apps/desktop/stories/app-shell.stories.tsx | 58 +- .../src/__tests__/prompt-anchor-rail.test.ts | 396 ++-------- ... => prompt-rail-reading-position.test.tsx} | 163 ++-- .../transcript-scroll-authority.test.ts | 87 ++- .../ui/src/__tests__/use-chat-scroll.test.tsx | 530 +++++++------ packages/ui/src/chat-view.tsx | 10 +- packages/ui/src/prompt-anchor-rail.tsx | 524 +------------ .../ui/src/transcript-scroll-authority.tsx | 54 +- packages/ui/src/use-chat-scroll.ts | 140 ++-- 38 files changed, 1837 insertions(+), 3968 deletions(-) rename packages/ui/src/__tests__/{prompt-rail-observer-identity.test.tsx => prompt-rail-reading-position.test.tsx} (50%) diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 5179ef42f7..1bde533a6d 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -21,6 +21,8 @@ import { expect, test } from './fixtures'; const GAP = '.maka-transcript-gap-row'; const TURN = '.maka-transcript-turn'; +/** Turns the partial-history fixture seeds. */ +const PARTIAL_HISTORY_TURN_COUNT = 18; test('bounded transcript ranges expose only their truthful boundary gaps', async ({ partialHistoryWindow: page, @@ -51,7 +53,8 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async name: /^(?:加载较新消息|Load newer messages)$/, })).toBeVisible(); await expect(page.locator(GAP)).toHaveCount(1); - expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); + // A jump lands on its own page, not on the whole history. + expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); const loadNewer = newerGap.getByRole('button', { name: /^(?:加载较新消息|Load newer messages)$/, @@ -64,12 +67,14 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async await loadNewer.click(); await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible(); - await expect(olderGap).toBeVisible(); + // Paging newer used to push the oldest Turn out of a Host-bounded range and + // put an older gap back. The Renderer owns the window now and keeps what the + // reader can still reach, so the only truthful boundary is still the newer one. + await expect(olderGap).toHaveCount(0); await expect(newerGap).toBeVisible(); + await expect(page.locator(GAP)).toHaveCount(1); await expect(loadNewer).toBeEnabled(); await expect(oldestPrompt).toBeVisible(); - await expect(page.locator(GAP)).toHaveCount(2); - expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); const returnToLatest = page.getByRole('button', { name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, @@ -77,8 +82,11 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async await expect(returnToLatest).toBeVisible(); await returnToLatest.click(); - await expect(page.locator('[data-turn-id="turn-partial-history-18"]')).toBeVisible(); + // Reading the tail page and rebuilding the window around it is slower than + // the paging above, and measured past the suite's 10s expect timeout here. + await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)) + .toBeVisible({ timeout: 30_000 }); await expect(newerGap).toHaveCount(0); await expect(oldestPrompt).toBeVisible(); - expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); + expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); }); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 4a9dcb1530..54458fe3de 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -37,12 +37,24 @@ import type { CDPSession, Page } from '@playwright/test'; import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; import { expect, test } from './fixtures'; const SCROLLER = '[data-chat-scroll-container="true"]'; const TURN = '.maka-transcript-turn'; +/** + * The mounted range is now a band of pixels, not a Host constant: useChatScroll + * keeps the Turns within four screens of the reader and drops what sits beyond + * six, so what bounds this count is the viewport these tests set (700px) and + * how tall a fixture Turn is — no number the Main tail cache owns. + * + * Generous on purpose. The property worth guarding is that paging through 120 + * Turns stops adding Turns; a range that kept everything it paged in would + * mount all 120, and a band that quietly doubled would pass no threshold that + * left this much room. + */ +const MOUNTED_TURNS_MAX = 40; + declare global { interface Window { __makaTranscriptCost?: { @@ -149,7 +161,32 @@ async function sample(page: Page): Promise { }); } +/** + * A transcript opened at its tail keeps fetching older history until two + * screens of it sit above the reader, and trims what falls outside the band it + * retains, so the mounted rows churn for as long as that runs. Wait for the + * window to stop moving before touching a row: a locator resolved mid-churn + * points at an element the Renderer has already unmounted. + */ +async function settled(page: Page): Promise { + const mounted = async (): Promise => page.evaluate(() => { + const turns = document.querySelectorAll('[data-turn-id]'); + return `${turns.length}:${turns[0]?.getAttribute('data-turn-id')}`; + }); + let previous = await mounted(); + await expect + .poll(async () => { + await page.waitForTimeout(250); + const current = await mounted(); + const stable = current === previous; + previous = current; + return stable; + }) + .toBe(true); +} + async function moveToTail(page: Page): Promise { + await settled(page); await page.locator(TURN).last().scrollIntoViewIfNeeded(); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), @@ -232,9 +269,10 @@ test('the browser skips the Turns the reader has scrolled past', async ({ /** * The bound the Desktop transcript is built on: paging back through a history - * far longer than the active range mounts a bounded number of Turns, not a - * growing one. Sampled at every page rather than only at the end, because a - * range that overshoots and is trimmed afterwards is the regression. + * far longer than the retained band mounts a bounded number of Turns, not a + * growing one. Sampled at every page rather than only at the end, because the + * regression is a range that grows while the reader travels and is only trimmed + * once they stop. */ test('paging back through the whole history keeps the mounted range bounded', async ({ promptRailWindow: page, @@ -267,14 +305,14 @@ test('paging back through the whole history keeps the mounted range bounded', as expect(pages).toBeGreaterThan(0); await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); - expect(mountedMax).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); - // Coming back from the far end is a range reload, not a scroll: the Host - // resolves a new window around the tail and the renderer mounts it. The - // suite's 10s expect timeout is sized for UI that is already on screen, and - // this step measured past it on a loaded CI runner. + // Coming back from the far end reads the tail page and rebuilds the window + // around it, so it is slower than the scrolling above. The suite's 10s expect + // timeout is sized for UI that is already on screen, and this step measured + // past it on a loaded CI runner. await returnToLatest(page); await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) .toHaveCount(1, { timeout: 30_000 }); - expect(await turns.count()).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); }); diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 4758d5ad67..b9e069ef8b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -33,11 +33,8 @@ import { } from '../../renderer/app-shell-session-ui-state.js'; import { createTranscriptRestoreLifecycle, - loadTranscriptHistory, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryGates, - type TranscriptHistoryPending, } from '../../renderer/features/conversation/testing.js'; function boundaryRequest(requestId: string): SandboxBoundaryRequestEvent { @@ -67,93 +64,6 @@ function answeredArm(turnId: string) { return confirmLiveTurn(armLiveTurn(turnId), turnId)!; } -function deferred() { - let resolve!: () => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function deferredHistoryController() { - const before = deferred(); - const after = deferred(); - const latest = deferred(); - const calls: string[] = []; - return { - controller: { - loadBefore: () => { - calls.push('before'); - return before.promise; - }, - loadAfter: (maxBytes: number, anchorTurnId?: string) => { - calls.push(`after:${maxBytes}:${anchorTurnId}`); - return after.promise; - }, - loadLatest: () => { - calls.push('latest'); - return latest.promise; - }, - }, - calls, - settleBefore: before.resolve, - settleAfter: after.resolve, - settleLatest: latest.resolve, - failBefore: before.reject, - }; -} - -function crossSessionGateScenario() { - type HistoryRequest = Parameters[0]['request']; - const gates: TranscriptHistoryGates = new WeakMap(); - const sessionIds = { a: 'session', b: 'session:a' } as const; - const sides = { - a: deferredHistoryController(), - b: deferredHistoryController(), - }; - let active: 'a' | 'b' = 'a'; - let range: object = sides.a.controller; - let currentPending: TranscriptHistoryPending | undefined; - const pending = { - a: [] as Array | undefined>, - b: [] as Array | undefined>, - }; - const errors = { a: [] as unknown[], b: [] as unknown[] }; - return { - sides, - pending, - errors, - currentPending: () => currentPending, - switchTo(id: 'a' | 'b') { - active = id; - range = sides[id].controller; - }, - load( - id: 'a' | 'b', - request: { target: 'earlier' | 'later' | 'latest'; anchorTurnId?: string }, - ) { - const side = sides[id]; - return loadTranscriptHistory({ - gates, - sessionId: sessionIds[id], - request, - controller: side.controller, - maxBytes: 4096, - isCurrent: () => active === id && range === side.controller, - setPending: (update) => { - currentPending = update(currentPending); - pending[id].push(currentPending?.sessionId === sessionIds[id] - ? { target: currentPending.target } - : undefined); - }, - onError: (error) => errors[id].push(error), - }); - }, - }; -} - function seededState(): AppShellSessionUiState { return { ...createInitialAppShellSessionUiState(), @@ -405,7 +315,6 @@ describe('app shell session UI state controller', () => { it('enriches a Turn-only reading anchor when its range sequence arrives later', async () => { let anchor: { turnId: string; sequence?: number } | undefined; - const admitted: Array = []; restoreSessionTranscriptRange({ lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', @@ -418,7 +327,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 17, snapshot: () => ({ messages: [] }), }, - setReadingAnchor: async (sequence) => { admitted.push(sequence); }, loadAround: async () => assert.fail('the resident Turn must not load another range'), }, isCurrent: () => true, @@ -430,7 +338,7 @@ describe('app shell session UI state controller', () => { assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(admitted, [17]); + assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); }); it('does not enrich a reading anchor from another Session range', () => { @@ -451,7 +359,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 17, snapshot: () => ({ messages: [] }), }, - setReadingAnchor: async () => {}, loadAround: async () => assert.fail('a stale range must not load'), }, isCurrent: () => true, @@ -480,7 +387,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), }, - setReadingAnchor: async () => {}, loadAround: async () => assert.fail('a Turn-only anchor has no load target'), }, isCurrent: () => true, @@ -516,7 +422,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 29, snapshot: () => ({ messages: [{ id: 'latest' }] }), }, - setReadingAnchor: async () => assert.fail('a missing durable Turn must load its range'), loadAround: async (sequence: number) => { loadedSequence = sequence; }, @@ -539,155 +444,6 @@ describe('app shell session UI state controller', () => { assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' }); }); - it('starts another Session history load without waiting behind an in-flight load elsewhere', async () => { - const scenario = crossSessionGateScenario(); - const stale = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.sides.a.calls, ['before']); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); - - scenario.switchTo('b'); - const navigation = scenario.load('b', { target: 'latest' }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.sides.b.calls, ['latest']); - assert.deepEqual(scenario.pending.b, [{ target: 'latest' }]); - - scenario.sides.a.settleBefore(); - await stale; - assert.deepEqual(scenario.currentPending(), { sessionId: 'session:a', target: 'latest' }); - scenario.sides.b.settleLatest(); - await navigation; - }); - - it('leaves the switched-to Session untouched when a stale Session load settles late', async () => { - const scenario = crossSessionGateScenario(); - const stale = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - scenario.switchTo('b'); - const navigation = scenario.load('b', { target: 'latest' }); - scenario.sides.b.settleLatest(); - await navigation; - assert.deepEqual(scenario.pending.b, [{ target: 'latest' }, undefined]); - assert.deepEqual(scenario.sides.b.calls, ['latest']); - - scenario.sides.a.settleBefore(); - await stale; - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.pending.b, [{ target: 'latest' }, undefined]); - assert.deepEqual(scenario.sides.b.calls, ['latest']); - assert.deepEqual(scenario.errors.b, []); - }); - - it('reports a late history load failure to its own Session alone', async () => { - const scenario = crossSessionGateScenario(); - const stale = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - scenario.switchTo('b'); - scenario.sides.a.failBefore(new Error('earlier read failed')); - await stale; - assert.deepEqual(scenario.errors.a, []); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); - assert.deepEqual(scenario.pending.b, []); - }); - - it('replays the queued latest load once after an in-flight load settles on the same Session', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queuedEarlier = scenario.load('a', { target: 'earlier', anchorTurnId: 'turn-anchor' }); - const queuedLatest = scenario.load('a', { target: 'latest' }); - assert.deepEqual(scenario.sides.a.calls, ['before']); - - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); - scenario.sides.a.settleLatest(); - await Promise.allSettled([queuedEarlier, queuedLatest]); - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'latest' }, - undefined, - ]); - }); - - it('replays a queued forward load with its reading anchor after a backward load settles', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - const queued = scenario.load('a', { target: 'later', anchorTurnId: 'turn-anchor' }); - assert.deepEqual(scenario.sides.a.calls, ['before']); - - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'after:4096:turn-anchor']); - scenario.sides.a.settleAfter(); - await queued; - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'later' }, - undefined, - ]); - }); - - it('keeps the queued latest load when adjacent requests arrive after it', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queuedLatest = scenario.load('a', { target: 'latest' }); - const queuedEarlier = scenario.load('a', { target: 'earlier', anchorTurnId: 'turn-anchor' }); - const queuedLater = scenario.load('a', { target: 'later', anchorTurnId: 'turn-anchor' }); - assert.deepEqual(scenario.sides.a.calls, ['before']); - - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); - scenario.sides.a.settleLatest(); - await Promise.allSettled([queuedLatest, queuedEarlier, queuedLater]); - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'latest' }, - undefined, - ]); - }); - - it('does not replay a settled load after its Session range was replaced', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queued = scenario.load('a', { target: 'latest' }); - scenario.switchTo('b'); - scenario.sides.a.settleBefore(); - await inFlight; - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.sides.a.calls, ['before']); - assert.deepEqual(scenario.sides.b.calls, []); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); - scenario.sides.a.settleLatest(); - await queued; - }); - - it('replays a queued load through its own Session callbacks', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queued = scenario.load('a', { target: 'latest' }); - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); - scenario.sides.a.settleLatest(); - await queued; - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'latest' }, - undefined, - ]); - assert.deepEqual(scenario.pending.b, []); - assert.deepEqual(scenario.sides.b.calls, []); - }); - it('keeps the synchronous live-turn ref aligned with reducer updates', () => { const controller = createAppShellSessionUiStateController(); const projection = armLiveTurn('turn-1'); diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 6c84432260..798ece654a 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -26,9 +26,9 @@ import { encodeDesktopTranscriptSnapshot, } from '../desktop-transcript-ipc.js'; import { - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptReconnectRecovery, @@ -123,10 +123,6 @@ test('moves a fragmented overlay record to durable storage without duplicating i const change = [...encodeDesktopTranscriptChange(identity, { durableThrough: 4, durableUpserts: [{ sequence: 4, message }], - evictedDurableSequences: [], - completedOverlayMessageIds: [message.id], - hasOlder: true, - hasNewer: false, })]; for (const batch of change) store.accept(batch); assert.deepEqual(store.snapshot().messages, [message]); @@ -136,7 +132,7 @@ test('moves a fragmented overlay record to durable storage without duplicating i assert.deepEqual(store.snapshot().messages, [message]); }); -test('retains the newest observed durable prompt across eviction', () => { +test('tracks the newest resident durable prompt as the window changes', () => { const identity = { sessionId: 'session-1', generation: 'generation-1', @@ -159,13 +155,13 @@ test('retains the newest observed durable prompt across eviction', () => { for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 4, - durableUpserts: [{ sequence: 4, message: assistantMessage('latest') }], - evictedDurableSequences: [3], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); + durableUpserts: [{ sequence: 4, message: assistantMessage('latest') }], })) store.accept(batch); assert.equal(store.newestDurableUserSequence(), 3); + + store.retain(2, null); + assert.equal(store.newestDurableUserSequence(), 3); + store.retain(4, null); + assert.equal(store.newestDurableUserSequence(), null); }); test('drops stale transcript batches after a generation reset', () => { @@ -198,12 +194,7 @@ test('drops stale transcript batches after a generation reset', () => { { sessionId: 'session-1', generation: 'old', hostEpoch: 'host-1' }, { durableThrough: 3, - durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - }, + durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], }, )]; for (const batch of staleChange) assert.equal(store.accept(batch), false); assert.deepEqual(store.snapshot().messages, [nextMessage]); @@ -240,6 +231,9 @@ test('cached reload snapshots allow the same live transcript generation to resum async loadAround(_sequence, _maxBytes, navigation) { publish(identity.generation, `live-${opens}`, navigation?.navigationVersion); }, + async loadLatest(navigation) { + publish(identity.generation, `live-${opens}`, navigation?.navigationVersion); + }, async close() {}, }; }); @@ -257,10 +251,7 @@ test('cached reload snapshots allow the same live transcript generation to resum const updated = assistantMessage('live update', 'assistant-2'); for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 2, - durableUpserts: [{ sequence: 2, message: updated }], - evictedDurableSequences: [], completedOverlayMessageIds: [], - hasOlder: false, hasNewer: false, - })) assert.equal(store.accept(batch), true); + durableUpserts: [{ sequence: 2, message: updated }], })) assert.equal(store.accept(batch), true); assert.deepEqual(store.snapshot().messages, [assistantMessage('live-3'), updated]); } finally { await controller.close(); @@ -286,10 +277,7 @@ test('a replacement live generation retires the previous replica through cached sessionId: 'session-1', generation, hostEpoch: 'host-1', }, { durableThrough: 2, - durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], - evictedDurableSequences: [], completedOverlayMessageIds: [], - hasOlder: false, hasNewer: false, - })) assert.equal(store.accept(batch), false); + durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], })) assert.equal(store.accept(batch), false); } assert.strictEqual(store.snapshot(), replacement); assert.deepEqual(store.snapshot().messages, [assistantMessage('replacement-live')]); @@ -322,12 +310,7 @@ test('keeps unchanged message references stable across immutable range snapshots for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 2, - durableUpserts: [{ sequence: 2, message: secondMessage }], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); + durableUpserts: [{ sequence: 2, message: secondMessage }], })) store.accept(batch); const second = store.snapshot(); assert.notStrictEqual(second, first); @@ -364,11 +347,11 @@ test('bounds the default active transcript range by Turn identities', async () = const snapshot = replica.snapshot(); assert.equal( new Set(snapshot.durable.map(({ message }) => message.turnId)).size, - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, ); assert.equal( snapshot.durable[0]?.sequence, - messages.length - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + messages.length - DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, ); assert.equal(snapshot.durable.at(-1)?.sequence, 199); assert.equal(snapshot.hasOlder, true); @@ -481,349 +464,6 @@ test('keeps an oversized latest Turn visible before a trailing session note', as assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === latest.identity)); }); -test('keeps an oversized latest Turn when returning from history to a trailing session note', async () => { - const older = { - identity: 0, - message: { ...assistantMessage('older', 'assistant-older'), turnId: 'turn-older' }, - }; - const latest = { - identity: 1, - message: { - ...assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-latest'), - turnId: 'turn-latest', - }, - }; - const trailingNote = { - identity: 2, - message: { - type: 'system_note' as const, - id: 'mode-change-latest', - ts: 3, - kind: 'mode_change' as const, - }, - }; - const bootstrapPage = { - ...transcriptPage('older', 'older', trailingNote.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; - const olderPage = { - ...transcriptPage('older', null, trailingNote.identity), - rangeBoundarySequence: older.identity, - protectedTurnSequence: older.identity, - }; - const latestPage = { - ...transcriptPage('older', null, trailingNote.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: trailingNote.identity, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { - ...bootstrapPage, - source: 'overlay', - rangeBoundarySequence: null, - protectedTurnSequence: null, - }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => page === bootstrapPage - ? { messages: [latest, trailingNote], nextCursor: 'older' } - : page === olderPage - ? { messages: [older], nextCursor: null } - : { messages: [latest, trailingNote], nextCursor: null }, - loadTranscriptPage: async (input) => input.anchorSequence === latest.identity - ? olderPage - : latestPage, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle); - - await replica.loadBefore(latest.identity, 128 * 1024); - assert.equal(replica.snapshot().hasNewer, true); - - await replica.loadAround(trailingNote.identity, 128 * 1024); - - assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === latest.identity)); -}); - -test('keeps a bounded contiguous window while moving between history and the tail', async () => { - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'older' as const, - throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const olderPage = page('older'); - const latestPage = page(null); - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: messages.slice(3), nextCursor: 'older' } - : candidate === olderPage - ? { messages: messages.slice(1, 3), nextCursor: 'older' } - : { messages: messages.slice(4), nextCursor: null }, - loadTranscriptPage: async (input) => input.anchorSequence === 3 ? olderPage : latestPage, - async close() {}, - }); - const maxResidentBytes = ( - Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') - + Buffer.byteLength(JSON.stringify(messages[1]!.message), 'utf8') - + 1 - ); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes, - }); - - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [3, 4]); - await replica.loadBefore(3, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2, 3]); - assert.equal(replica.snapshot().hasNewer, true); - - await replica.loadAround(4, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [4]); - assert.equal(replica.snapshot().hasNewer, false); - assert.ok(replica.residentBytes <= maxResidentBytes); -}); - -test('retains the reading anchor while an older page replaces the far edges', async () => { - const messages = Array.from({ length: 8 }, (_, sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const bootstrapPage = transcriptPage('older', 'older', 7); - const olderPage = transcriptPage('older', null, 7); - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 7, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 7), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => ({ - messages: page === bootstrapPage ? messages.slice(4) : messages.slice(0, 4), - nextCursor: page === bootstrapPage ? 'older' : null, - }), - loadTranscriptPage: async () => olderPage, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 1024 * 1024, - maxResidentTurns: 4, - }); - - await replica.loadBefore(4, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - - const snapshot = replica.snapshot(); - assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [2, 3, 4, 5]); - assert.equal(snapshot.hasOlder, true); - assert.equal(snapshot.hasNewer, true); -}); - -for (const { stride, textBytes } of [1, 3].flatMap((stride) => - [0, 300 * 1024, 600 * 1024].map((textBytes) => ({ stride, textBytes })), -)) { - test(`scrolls both ways through bounded stride-${stride} history with ${textBytes}-byte Turns`, async () => { - const messages = Array.from({ length: 40 }, (_, index) => ({ - identity: index * stride, - message: { ...assistantMessage('x'.repeat(textBytes), `assistant-${index}`), turnId: `turn-${index}` }, - })); - const largestTurnBytes = Math.max(...messages.map(({ message }) => - Buffer.byteLength(JSON.stringify(message), 'utf8'), - )); - const maxNavigationBytes = Math.max(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, 2 * largestTurnBytes); - const pageTurns = textBytes === 0 ? 10 : 1; - const through = 39 * stride; - const pages = new Map(); - const makePage = (direction: 'older' | 'newer', anchor: number | null) => { - const candidates = messages.filter(({ identity }) => anchor === null - || (direction === 'older' ? identity < anchor : identity > anchor)); - const nextCursor = candidates.length > pageTurns ? 'more' : null; - const page = transcriptPage(direction, nextCursor, through); - pages.set(page, { - messages: direction === 'older' ? candidates.slice(-pageTurns) : candidates.slice(0, pageTurns), - nextCursor, - }); - return page; - }; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - async close() {}, - transcriptBootstrap: { - throughSequence: through, - overlayMessageCount: 0, - durable: makePage('older', null), - overlay: { ...transcriptPage('older', null, through), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => pages.get(page)!, - loadTranscriptPage: async (input) => makePage(input.direction, input.anchorSequence), - }); - const store = transcriptStore(); - let navigationVersion = 0; - const replica = await DesktopTranscriptReplica.prepare(handle, { - generation: 'generation-1', - onChange: (current, change) => { - for (const batch of encodeDesktopTranscriptChange({ ...current.snapshot(), navigationVersion }, change)) store.accept(batch); - }, - }); - for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) store.accept(batch); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, - readThroughMessageId: null, - loadBefore: (anchor, maxBytes, navigation) => { - navigationVersion = navigation?.navigationVersion ?? navigationVersion; - return replica.loadBefore(anchor, maxBytes!); - }, - loadAfter: (anchor, maxBytes, navigation) => { - navigationVersion = navigation?.navigationVersion ?? navigationVersion; - return replica.loadAfter(anchor, maxBytes!); - }, - loadAround: async () => { throw new Error('ordinary scrolling must not replace the range'); }, - close: async () => replica.close(), - })); - for (const direction of ['older', 'newer', 'older', 'newer'] as const) { - let steps = 0; - while (direction === 'older' ? store.range().hasOlder : store.range().hasNewer) { - assert.ok(++steps <= 40, 'paging must make progress'); - const before = replica.snapshot(); - const anchor = (direction === 'older' ? before.durable[0] : before.durable.at(-1))!; - await (direction === 'older' - ? controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchor.message.turnId) - : controller.loadAfter(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchor.message.turnId)); - const after = replica.snapshot(); - assert.ok(after.durable.some(({ sequence }) => sequence === anchor.sequence)); - assert.ok(after.durable.length <= DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); - assert.ok(replica.residentBytes <= maxNavigationBytes, - 'only the reading Turn and one adjacent Turn may exceed the soft range budget'); - assert.ok(direction === 'older' - ? after.durable[0]!.sequence < before.durable[0]!.sequence - : after.durable.at(-1)!.sequence > before.durable.at(-1)!.sequence, - 'every adjacent edge load must make progress'); - for (let i = 1; i < after.durable.length; i++) { - assert.equal(after.durable[i]!.sequence - after.durable[i - 1]!.sequence, stride); - } - } - assert.equal(direction === 'older' ? store.range().oldestSequence : store.range().newestSequence, - direction === 'older' ? 0 : through); - } - // Global pressure may reclaim the range even after navigation used the - // atomic-Turn exception; the protection is local to that operation. - replica.trimDurable(128 * 1024); - assert.ok(replica.residentBytes <= 128 * 1024); - await controller.close(); - }); -} - -test('delivers a mid-session tail append after following the tail from a history window', async () => { - // A follow-tail intent must recover the tail even if the resident cache - // still contains history when the Host advances. - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'older' as const, - throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const olderPage = page('older'); - // The tail reload after the append: a fresh newest window ending at seq 5, - // with older history still available below it. - const tailPage = { ...page('older'), throughSequence: 5 }; - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: messages.slice(3), nextCursor: 'older' } - : candidate === tailPage - ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(1, 3), nextCursor: 'older' }, - loadTranscriptPage: async (input) => input.throughSequence === 5 ? tailPage : olderPage, - async close() {}, - }); - const maxResidentBytes = ( - Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') - + Buffer.byteLength(JSON.stringify(messages[1]!.message), 'utf8') - + 1 - ); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes, - onChange: (_replica, change) => changes.push(change), - }); - - await replica.loadBefore(3, 128 * 1024); - assert.equal(replica.snapshot().hasNewer, true); - replica.setNavigation('followTail'); - changes.splice(0); - - // The Host persists a new assistant message (sequence 5) and advances. - await replica.advance(5); - - const upserts = changes.flatMap((change) => change.durableUpserts.map(({ sequence }) => sequence)); - assert.ok(upserts.includes(5), 'the tail append must be delivered to open consumers'); - assert.equal(replica.durableThrough, 5); -}); - test('advances a projected transcript across hidden durable records', async () => { const visible = (sequence: number) => ({ identity: sequence, @@ -966,102 +606,6 @@ test('keeps an oversized settled Turn visible before a trailing session note', a assert.deepEqual(snapshot.overlay, []); }); -test('does not resurrect a discarded replica when a tail re-anchor is in flight', async () => { - // Guards the concurrency edge introduced by re-anchoring on `hasNewer`: the - // re-anchor now awaits a page load, and `discard()` (memory reclaim for a - // non-visible session) can run during that await. When the page resolves the - // replica must stay non-resident and empty — repopulating durable state here - // would undo the eviction and blow the memory bound. - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'older' as const, - throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const olderPage = page('older'); - const tailPage = { ...page('older'), throughSequence: 5 }; - let releaseTail: () => void = () => {}; - const tailGate = new Promise((resolve) => { - releaseTail = resolve; - }); - let signalTailEntered: () => void = () => {}; - const tailEntered = new Promise((resolve) => { - signalTailEntered = resolve; - }); - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: messages.slice(3), nextCursor: 'older' } - : candidate === tailPage - ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(1, 3), nextCursor: 'older' }, - loadTranscriptPage: async (input) => { - if (input.throughSequence === 5) { - // Signal that catch-up is now parked inside the re-anchor's page await, - // so the test can `discard()` at exactly that point. - signalTailEntered(); - await tailGate; - return tailPage; - } - return olderPage; - }, - async close() {}, - }); - const maxResidentBytes = ( - Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') - + Buffer.byteLength(JSON.stringify(messages[1]!.message), 'utf8') - + 1 - ); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes, - onChange: (_replica, change) => changes.push(change), - }); - - await replica.loadBefore(3, 128 * 1024); - assert.equal(replica.snapshot().hasNewer, true); - replica.setNavigation('followTail'); - changes.splice(0); - - // Start the tail re-anchor; wait until catch-up is parked inside its page - // await, then reclaim memory before the page resolves. - const advancing = replica.advance(5); - await tailEntered; - replica.discard(); - assert.equal(replica.resident, false); - releaseTail(); - await advancing; - - const upserts = changes.flatMap((change) => change.durableUpserts.map(({ sequence }) => sequence)); - assert.ok(!upserts.includes(5), 'a discarded replica must not be repopulated by an in-flight re-anchor'); - assert.equal(replica.resident, false); - assert.equal(replica.residentBytes, 0); -}); - for (const direction of ['older', 'newer'] as const) { test(`does not resurrect a discarded replica when ${direction} history load is in flight`, async () => { // A pending page must not repopulate or publish a reclaimed replica. @@ -1215,154 +759,6 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is assert.equal(replica.residentBytes, 0); }); -test('loads a history target with newer messages available below it', async () => { - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: assistantMessage(String(sequence), `assistant-${sequence}`), - })); - const bootstrapPage = transcriptPage('older', null, 4); - const aroundPage = transcriptPage('newer', 'newer', 4); - const inputs: Array<{ direction: string; anchorSequence: number | null }> = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 4), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => page === bootstrapPage - ? { messages: messages.slice(4), nextCursor: null } - : { messages: messages.slice(0, 3), nextCursor: 'newer' }, - loadTranscriptPage: async (input) => { - inputs.push(input); - return input.direction === 'older' ? olderProbePage(0, false) : aroundPage; - }, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 128 * 1024, - }); - - await replica.loadAround(0, 128 * 1024); - - assert.deepEqual( - inputs.map(({ direction, anchorSequence }) => ({ direction, anchorSequence })), - [ - { direction: 'newer', anchorSequence: null }, - // Nothing older than the anchor exists, and only this read can say so. - { direction: 'older', anchorSequence: 0 }, - ], - ); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1, 2]); - assert.equal(replica.snapshot().hasOlder, false); - assert.equal(replica.snapshot().hasNewer, true); -}); - -test('keeps an oversized transcript sparse while moving between indexed prompts', async () => { - const messages = syntheticLargeTranscript(); - const totalBytes = messages.reduce( - (total, entry) => total + Buffer.byteLength(JSON.stringify(entry.message), 'utf8'), - 0, - ); - assert.ok(totalBytes > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES * 2); - - const bootstrapPage = transcriptPage('older', 'older', 15); - const historicalPage = transcriptPage('newer', 'newer', 15); - const intermediatePage = transcriptPage('newer', 'newer', 15); - const latestPage = transcriptPage('older', 'older', 15); - const requests: Array<{ - direction: 'older' | 'newer'; - anchorSequence: number | null; - maxBytes: number; - }> = []; - const rendererStore = transcriptStore(); - const generation = 'oversized-range'; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 15, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 15), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => page === bootstrapPage || page === latestPage - ? { messages: messages.slice(12, 16), nextCursor: 'older' } - : page === historicalPage - ? { messages: messages.slice(0, 5), nextCursor: 'newer' } - : { messages: messages.slice(6, 11), nextCursor: 'newer' }, - loadTranscriptPage: async (input) => { - requests.push({ - direction: input.direction, - anchorSequence: input.anchorSequence, - maxBytes: input.maxBytes, - }); - if (input.direction === 'older') { - if (input.maxBytes > 1) return latestPage; - return olderProbePage(input.anchorSequence!, input.anchorSequence !== 0); - } - return input.anchorSequence === null ? historicalPage : intermediatePage; - }, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - generation, - onChange: (_current, change) => { - for (const batch of encodeDesktopTranscriptChange({ - sessionId: 'session-1', - generation, - hostEpoch: 'host-1', - }, change)) rendererStore.accept(batch); - }, - }); - for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) { - rendererStore.accept(batch); - } - - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']); - assert.equal(rendererStore.range().hasNewer, false); - assertRangeFitsBudget(rendererStore); - - await replica.loadAround(0, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1, 2, 3, 4]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 1', 'Prompt 2', 'Prompt 3']); - assert.equal(rendererStore.range().hasOlder, false); - assert.equal(rendererStore.range().hasNewer, true); - assertRangeFitsBudget(rendererStore); - - await replica.loadAround(6, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [6, 7, 8, 9, 10]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 4', 'Prompt 5', 'Prompt 6']); - assert.equal(rendererStore.range().hasOlder, true); - assert.equal(rendererStore.range().hasNewer, true); - assertRangeFitsBudget(rendererStore); - - await replica.loadAround(15, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']); - assert.equal(rendererStore.range().hasOlder, true); - assert.equal(rendererStore.range().hasNewer, false); - assertRangeFitsBudget(rendererStore); - - // Every jump that is not to the tail pays one extra single-byte read, the - // only thing that can say whether the anchor has anything older than it. - assert.deepEqual(requests, [ - { direction: 'newer', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - { direction: 'older', anchorSequence: 0, maxBytes: 1 }, - { direction: 'newer', anchorSequence: 5, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - { direction: 'older', anchorSequence: 6, maxBytes: 1 }, - { direction: 'older', anchorSequence: 16, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - ]); - replica.close(); -}); - test('rejects an overlay that exceeds its cache budget', async () => { const messages = [ assistantMessage('x'.repeat(700), 'overlay-1'), @@ -1386,40 +782,6 @@ test('rejects an overlay that exceeds its cache budget', async () => { ); }); -test('keeps history resident when an active overlay uses its own cache budget', async () => { - const overlay = assistantMessage('o'.repeat(700), 'overlay-1'); - const historical = assistantMessage('h'.repeat(700), 'history-1'); - const bootstrapPage = transcriptPage('older', 'older', 1); - const olderPage = transcriptPage('older', null, 1); - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 1, - overlayMessageCount: 1, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 1), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [overlay], - decodeTranscriptPage: async (page) => page === olderPage - ? { messages: [{ identity: 0, message: historical }], nextCursor: null } - : { messages: [], nextCursor: 'older' }, - loadTranscriptPage: async () => olderPage, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 1_024, - maxOverlayBytes: 1_024, - maxMessageBytes: 1_024, - }); - - await replica.loadBefore(null, 128 * 1024); - - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0]); - assert.equal(replica.snapshot().hasOlder, false); -}); - test('transfers prepared transcript bytes into active replica accounting', async () => { const message = assistantMessage('prepared', 'overlay-1'); const messageBytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); @@ -1496,6 +858,7 @@ test('reopens a failed transcript range with a fresh generation', async () => { async loadBefore() {}, async loadAfter() {}, async loadAround() {}, + async loadLatest() {}, async close() {}, }; }); @@ -1550,6 +913,7 @@ test('forwards a larger logical history range without changing batch size', asyn sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + navigationVersion: 0, durableThrough: 4, durable: [ { sequence: 1, message: assistantMessage('earlier') }, @@ -1579,17 +943,17 @@ test('forwards a larger logical history range without changing batch size', asyn request = { anchorSequence, maxBytes }; }, async loadAround() {}, + async loadLatest() {}, async close() {}, })); - await controller.loadBefore(512 * 1024, 'turn-2'); + await controller.loadBefore(512 * 1024); - assert.deepEqual(request, { anchorSequence: 2, maxBytes: 512 * 1024 }); - await controller.loadAfter(512 * 1024, 'turn-2'); + assert.deepEqual(request, { anchorSequence: 1, maxBytes: 512 * 1024 }, + 'backward reads start at the oldest record the window holds'); + await controller.loadAfter(512 * 1024); assert.deepEqual(request, { anchorSequence: 3, maxBytes: 512 * 1024 }, - 'forward reads start after the last resident record of the visible turn'); - await controller.loadAfter(512 * 1024, 'evicted-turn'); - assert.deepEqual(request, { anchorSequence: 3, maxBytes: 512 * 1024 }); + 'forward reads start at the newest record the window holds'); await controller.close(); }); @@ -1611,12 +975,7 @@ test('waits for the required durable message on the current transcript generatio const waiting = store.waitForDurableMessage('assistant-1', 100); for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 0, - durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); + durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], })) store.accept(batch); assert.equal(await waiting, true); }); @@ -1689,24 +1048,6 @@ function transcriptPage( /** The one-byte read `loadAround` uses to ask whether `sequence` has anything * older than it: only the presence of a fragment answers, not its content. */ -function olderProbePage(sequence: number, exists: boolean) { - return { - ...transcriptPage('older', null, sequence), - fragments: exists - ? [ - { - kind: 'durable' as const, - sequence, - byteOffset: 0, - totalBytes: 1, - payloadDigest: null, - data: '', - }, - ] - : [], - }; -} - function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMessage }> { return Array.from({ length: 8 }, (_, index) => { const number = index + 1; @@ -1730,20 +1071,6 @@ function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMe }).flat(); } -function renderedUserPrompts(store: DesktopTranscriptRangeStore): string[] { - return store.snapshot().messages.flatMap((message) => - message.type === 'user' ? [message.text] : [], - ); -} - -function assertRangeFitsBudget(store: DesktopTranscriptRangeStore): void { - const bytes = store.snapshot().messages.reduce( - (total, message) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), - 0, - ); - assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); -} - function continuitySnapshot() { return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, @@ -1787,7 +1114,7 @@ test('cached fallback remains readable and retries once per observation generati return { ...identity, readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, loadAround: async () => {}, - close: async () => {}, + loadLatest: async () => {}, close: async () => {}, }; }, { onError: (error) => errors.push(error) }); const settle = () => new Promise((resolve) => setImmediate(resolve)); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 8da53cb213..5bb73dcbad 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -95,25 +95,33 @@ for (const phase of ['connecting', 'seeding'] as const) { } } -test('forward transcript paging is an observation operation scoped to the renderer', async () => { +test('window transcript reads are observation operations scoped to the renderer', async () => { const ipc = ipcHarness(); const observations = new RuntimeHostSessionObservationRegistry(); const calls: unknown[] = []; - observations.loadTranscriptAfter = async (request, targetId) => { calls.push({ request, targetId }); }; + observations.loadTranscriptAfter = async (request, targetId) => { calls.push({ command: 'after', request, targetId }); }; + observations.loadTranscriptLatest = async (request, targetId) => { calls.push({ command: 'latest', request, targetId }); }; registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); const request = { consumerId: 'guest-consumer', sessionId: 'shared-session', hostEpoch: 'host-1', - anchorSequence: 42, maxBytes: 512 * 1024, - navigationVersion: 7, intent: 'history' as const, preserveRange: false, - readingTurnId: 'reading-turn', + anchorSequence: 42, maxBytes: 512 * 1024, navigationVersion: 7, }; await ipc.invoke('sessions:transcript:load-after', request); - assert.deepEqual(calls, [{ request, targetId: 9 }]); + await ipc.invoke('sessions:transcript:load-latest', { ...request, anchorSequence: null }); + assert.deepEqual(calls, [ + { command: 'after', request, targetId: 9 }, + { command: 'latest', request: { ...request, anchorSequence: null }, targetId: 9 }, + ]); await assert.rejects( ipc.invoke('sessions:transcript:load-after', { ...request, anchorSequence: -1 }), /Invalid Desktop transcript range anchor/, ); - assert.equal(calls.length, 1); + // The Renderer owns the window, so every read must name the version it reads for. + await assert.rejects( + ipc.invoke('sessions:transcript:load-after', { ...request, navigationVersion: undefined }), + /Invalid Desktop transcript navigation/, + ); + assert.equal(calls.length, 2); }); test("keeps synthetic E2E interactions visible through Host hydration and retires their answer", async () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index a2efb98c7f..b2be49ef5f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -435,10 +435,7 @@ test('restores transcript consumers across Host replacement', async () => { generation, hostEpoch: `host-${generation}`, durableThrough: null, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, + fragments: [], hasOlder: false, hasNewer: false, reset: true, ready: true, @@ -453,6 +450,7 @@ test('restores transcript consumers across Host replacement', async () => { async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, async closeTranscript() {}, }); const first = source('first'); @@ -511,6 +509,7 @@ test('does not hold Host observation recovery on transcript replay', async () => async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, async closeTranscript() {}, }); const first = source('first'); @@ -541,6 +540,7 @@ test('does not hold Host observation recovery on transcript replay', async () => }, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, acknowledgeTranscript() { transcriptAcknowledged = true; }, @@ -565,6 +565,7 @@ test('does not hold Host observation recovery on transcript replay', async () => hostEpoch: 'host-second', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion: 1, }, transcriptTarget.id, ); @@ -605,6 +606,7 @@ test('fences transcript range failures to the current registration and Host sour loadTranscriptBefore, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, async closeTranscript() {}, }); const request = (consumerId: string, generation: string) => ({ @@ -613,6 +615,7 @@ test('fences transcript range failures to the current registration and Host sour hostEpoch: `host-${generation}`, anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion: 1, }); const closedFailure = deferred(); @@ -729,6 +732,7 @@ test('fences transcript range failures across same-source replica recovery', asy hostEpoch, anchorSequence: 1, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion: 1, }); const target: RuntimeHostTranscriptTarget = { id: 20, @@ -758,12 +762,10 @@ test('fences transcript range failures across same-source replica recovery', asy sequence: 1, reason: 'slow_consumer', }); - await waitFor(() => currentRangeStarted); - assert.equal( - batches.at(-1)?.generation, - opened.generation, - 'a failed recovery range does not replace the visible snapshot with an unrelated bootstrap', - ); + // Recovery installs a replacement replica and resets every consumer onto its + // tail; no page read is replayed on its behalf. + await waitFor(() => batches.some((batch) => batch.reset && batch.generation !== opened.generation)); + assert.equal(currentRangeStarted, false); staleRange.reject(new Error('stale replica rejected its range')); await assert.doesNotReject(staleLoad); @@ -771,6 +773,7 @@ test('fences transcript range failures across same-source replica recovery', asy observations.loadTranscriptBefore(request(opened.hostEpoch), target.id), (error) => error === currentFailure, ); + assert.equal(currentRangeStarted, true); await observations.close(); await observer.close(); }); @@ -1125,6 +1128,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion: 1, }, 22, ), @@ -1143,6 +1147,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'other-host', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion: 1, }, 22, ), @@ -1157,6 +1162,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion: 1, }, 22, ), @@ -1258,6 +1264,9 @@ test('coalesces transcript changes into one bounded delta while renderer deliver assert.equal(batches[1]!.reset, false); assert.equal(batches[1]!.durableThrough, 4); assert.equal(batches[1]!.fragments.length, 4); + assert.equal(batches[1]!.navigationVersion, undefined, 'tail growth is a broadcast, not an answer'); + assert.equal(batches[1]!.hasOlder, undefined); + assert.equal(batches[1]!.hasNewer, undefined); observer.acknowledgeTranscript( consumerId, batches[1]!.generation, @@ -1267,6 +1276,121 @@ test('coalesces transcript changes into one bounded delta while renderer deliver await observer.close(); }); +test('answers a window page read on its own navigation version and drops a stale one', async () => { + const events = new AsyncFrameQueue(); + const record = (sequence: number) => ({ + identity: sequence, + message: { + type: 'assistant' as const, + id: `a-${sequence}`, + turnId: `turn-${sequence}`, + ts: sequence, + text: String(sequence), + modelId: 'test-model', + }, + }); + const durablePage = (nextCursor: string | null): SessionTranscriptPage => ({ + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'older', + throughSequence: 2, + rawBytes: 1, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor, + }); + const bootstrap = durablePage(null); + const decoded = new Map>; + nextCursor: string | null; + }>([[bootstrap, { messages: [record(2)], nextCursor: null }]]); + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: { + throughSequence: 2, + overlayMessageCount: 0, + durable: bootstrap, + overlay: { ...bootstrap, source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + loadTranscriptPage: async (request) => { + // `loadAround` probes one row older than its anchor to learn + // whether history precedes it; that probe stays empty here. + const answer = request.direction === 'older' + ? request.maxBytes === 1 + ? { messages: [], nextCursor: null } + : { messages: [record(1)], nextCursor: 'older' } + : request.anchorSequence === 0 + ? { messages: [record(1)], nextCursor: 'newer' } + : { messages: [record(2)], nextCursor: null }; + const page = durablePage(answer.nextCursor); + decoded.set(page, answer); + return page; + }, + decodeTranscriptPage: async (page) => decoded.get(page)!, + async close() { + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-window'; + const request = (navigationVersion: number, anchorSequence: number | null) => ({ + consumerId, + sessionId: 'session-1', + hostEpoch: 'host-1', + anchorSequence, + maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion, + }); + await observer.openTranscript('session-1', consumerId, { + id: 26, + send(_channel, batch) { + batches.push(batch); + queueMicrotask(() => + observer.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, 26), + ); + }, + once() {}, + off() {}, + }); + batches.splice(0); + + await observer.loadTranscriptBefore(request(1, 2), 26); + assert.equal(batches.length, 1); + assert.equal(batches[0]!.navigationVersion, 1); + assert.equal(batches[0]!.reset, false, 'extending the window does not replace it'); + assert.equal(batches[0]!.hasOlder, true); + assert.equal(batches[0]!.hasNewer, undefined, 'an older page establishes only its older edge'); + + // The same version extends the window the Renderer already holds. + await observer.loadTranscriptAfter(request(1, 1), 26); + assert.equal(batches.length, 2); + assert.equal(batches[1]!.navigationVersion, 1); + assert.equal(batches[1]!.reset, false); + assert.equal(batches[1]!.hasNewer, false); + + await observer.loadTranscriptAround(request(2, 1), 26); + assert.equal(batches.length, 3); + assert.equal(batches[2]!.navigationVersion, 2); + assert.equal(batches[2]!.reset, true, 'a window-replacing command resets the Renderer'); + assert.equal(batches[2]!.hasOlder, false); + assert.equal(batches[2]!.hasNewer, true); + + await observer.loadTranscriptBefore(request(1, 2), 26); + assert.equal(batches.length, 3, 'a version the Renderer already abandoned is dropped silently'); + await observer.close(); +}); + test('does not let one backpressured transcript consumer block another', async () => { const events = new AsyncFrameQueue(); let decoded = 0; @@ -1462,6 +1586,7 @@ test('keeps a transcript consumer available after a delivery fails', async () => hostEpoch: opened.hostEpoch, anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigationVersion: 1, }, 25, ), diff --git a/apps/desktop/src/main/__tests__/transcript-identity.test.ts b/apps/desktop/src/main/__tests__/transcript-identity.test.ts index bd853f7083..4c43ae0398 100644 --- a/apps/desktop/src/main/__tests__/transcript-identity.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-identity.test.ts @@ -31,10 +31,7 @@ function batch(overrides: Partial = {}): DesktopTranscri generation: 'generation-1', hostEpoch: 'host-1', durableThrough: null, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, + fragments: [], hasOlder: false, hasNewer: false, reset: false, ready: true, diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts index 1a3ec68814..93301f41db 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts @@ -74,26 +74,24 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a await replica.advance(completeThrough); assertRecords(replica, second); - await replica.loadBefore(second[0]!.sequence, PAGE_BYTES); - assertRecords(replica, complete); - assert.equal(replica.snapshot().hasOlder, false, - 'older paging keeps the complete oversized Turn and its adjacent anchor'); - await replica.readAt(first[0]!.sequence); - assertRecords(replica, first); - assert.equal(replica.snapshot().hasNewer, true); + // A window read answers the Renderer without touching Main's tail cache. + const older = await replica.loadBefore(second[0]!.sequence, PAGE_BYTES); + assert.ok(older); + assert.deepEqual(older.durable, first, + 'older paging returns the complete oversized Turn adjacent to the anchor'); + assert.equal(older.hasOlder, false); + assert.equal(older.hasNewer, undefined, 'an older page establishes only its older edge'); + assertRecords(replica, second); for (let attempt = 0; attempt < 2; attempt += 1) { - await replica.followLatest(PAGE_BYTES); + const around = await replica.loadAround(first[0]!.sequence, PAGE_BYTES); + assert.ok(around); + assert.deepEqual(around.durable, complete, + 'a reset anchored on the oldest row reaches the current tail in one page'); + assert.equal(around.hasOlder, false); + assert.equal(around.hasNewer, false); assertRecords(replica, second); - assert.equal(replica.snapshot().hasOlder, true); - assert.equal(replica.snapshot().hasNewer, false); - await replica.loadAround(first[0]!.sequence, PAGE_BYTES); - assertRecords(replica, first); - assert.equal(replica.snapshot().hasOlder, false); - assert.equal(replica.snapshot().hasNewer, true); } - await replica.followLatest(PAGE_BYTES); - assertRecords(replica, second); } finally { opened?.replica.close(); await opened?.subscription.close(); @@ -121,10 +119,6 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { const expected = source.second.slice(0, source.second.findIndex(({ id }) => id === checkpoint) + 1) .filter((message) => message.type !== 'turn_state').map(({ id }) => id); assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), expected); - await replica.readAt(first[0]!.sequence); - assertRecords(replica, first); - await replica.followLatest(PAGE_BYTES); - assertRecords(replica, first); assert.ok(replica.messages().some(({ id }) => id === expected.at(-1)), 'the running Turn remains reachable'); const throughSequence = await ledger.appendThrough('completed-b'); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts index 2dff394ff4..7bfb1865ef 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -21,24 +21,24 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; -import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptNavigation, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; +import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; -import { RuntimeHostSessionObservationRegistry } from '../runtime-host-session-observation-registry.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -for (const kind of ['before', 'around', 'catch-up'] as const) { - test(`a newer reading intent invalidates an in-flight ${kind} page before it mutates or publishes`, async () => { +const PAGE_BYTES = 128 * 1024; + +for (const kind of ['before', 'around'] as const) { + test(`an invalidated ${kind} page neither answers its window nor touches the tail`, async () => { const entered = deferred(); const release = deferred(); const installed: number[][] = []; const bootstrap = page(1); - const pending = page(kind === 'catch-up' ? 2 : 1); + const pending = page(1); const older = record(0); const latest = record(1); - const appended = record(2); const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, @@ -48,7 +48,7 @@ for (const kind of ['before', 'around', 'catch-up'] as const) { }, loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => ({ - messages: candidate === bootstrap ? [latest] : kind === 'catch-up' ? [appended] : [older], + messages: candidate === bootstrap ? [latest] : [older], nextCursor: candidate === bootstrap ? 'older' : null, }), loadTranscriptPage: async () => { @@ -58,64 +58,27 @@ for (const kind of ['before', 'around', 'catch-up'] as const) { }, async close() {}, }), { onChange: (_replica, change) => installed.push(change.durableUpserts.map(({ sequence }) => sequence)) }); - const loading = kind === 'before' ? replica.loadBefore(1, 128 * 1024) - : kind === 'around' ? replica.loadAround(0, 128 * 1024) : replica.advance(2); + let current = true; + const isCurrent = () => current; + const loading = kind === 'before' + ? replica.loadBefore(1, PAGE_BYTES, isCurrent) + : replica.loadAround(0, PAGE_BYTES, isCurrent); await entered.promise; - const reading = replica.readAt(1); + // The Renderer replaced its window while this page was in flight. + current = false; release.resolve(); - await Promise.all([loading, reading]); + assert.equal(await loading, undefined); assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); - assert.ok(installed.every((sequences) => sequences.length === 0), 'superseded pages cannot upsert or evict the new reading range'); + assert.deepEqual(installed, []); replica.close(); }); } -test('repeated older paging retains at most the adjacent anchor pair and releases it as the reader moves', async () => { - const records = Array.from({ length: 8 }, (_, sequence) => record(sequence)); - const bootstrap = page(7); - const pages = new Map([[bootstrap, 7]]); - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 7, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => { - const sequence = pages.get(candidate)!; - return { messages: [records[sequence]!], nextCursor: sequence > 0 ? 'older' : null }; - }, - loadTranscriptPage: async (request) => { - const candidate = page(7); - pages.set(candidate, request.anchorSequence! - 1); - return candidate; - }, - async close() {}, - }), { maxResidentBytes: 64, maxResidentTurns: 2 }); - const maxTurnBytes = Math.max(...records.map(({ message }) => Buffer.byteLength(JSON.stringify(message)))); - assert.ok(maxTurnBytes > 64, 'each complete turn exceeds the soft byte budget'); - for (let anchor = 7; anchor > 0; anchor -= 1) { - await replica.loadBefore(anchor, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [anchor - 1, anchor]); - assert.ok(replica.residentBytes <= maxTurnBytes * 2, 'successive pages cannot accumulate protected turns'); - // First prove consecutive paging itself releases the old pair, then prove - // reading-anchor movement trims each remaining pair down to one turn. - if (anchor <= 4) { - await replica.readAt(anchor - 1); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [anchor - 1]); - assert.ok(replica.residentBytes <= maxTurnBytes); - } - } - assert.equal(replica.snapshot().hasOlder, false); - assert.equal(replica.snapshot().hasNewer, true); - replica.close(); -}); - -test('memory trimming cannot turn an already durable reading anchor into an unresolved live Turn', async () => { +test('a global cache trim empties the tail without publishing or reading history', async () => { const bootstrap = page(1); const decoded = new Map>([[bootstrap, record(1)]]); const requests: number[] = []; + const changes: number[] = []; const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, @@ -133,19 +96,19 @@ test('memory trimming cannot turn an already durable reading anchor into an unre return candidate; }, async close() {}, - }), { maxResidentBytes: 64 }); + }), { maxResidentBytes: 64, onChange: (_replica, change) => changes.push(change.durableUpserts.length) }); try { - await replica.readAt(1, undefined, record(1).message.turnId); await replica.advance(2); - assert.equal(replica.snapshot().hasNewer, true); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2], + 'catch-up keeps the newest Turn and drops the oldest'); + const published = changes.length; + requests.length = 0; + replica.trimDurable(0); + assert.deepEqual(replica.snapshot().durable, []); - requests.length = 0; - await replica.advance(3); - assert.deepEqual(replica.snapshot().durable, [], 'budget reclaim must not authorize following a later Turn'); - assert.equal(replica.durableThrough, 3); - assert.deepEqual(requests, [], 'a known durable anchor remains history after reclaim'); + assert.equal(changes.length, published, 'a cache trim is not a transcript change'); + assert.deepEqual(requests, [], 'a cache trim reads nothing back'); } finally { replica.close(); } @@ -172,44 +135,35 @@ test('a superseded fragmented reset cannot clear or complete the next navigation assert.strictEqual(store.snapshot(), committed); }); -test('follow latest invalidates before open resolves and a reload replays only that latest intent', async () => { +test('follow latest invalidates an in-flight history navigation before open resolves', async () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); const opening = deferred(); - const requests: Array<{ generation: string; anchor: number | null; navigation?: DesktopTranscriptNavigation }> = []; + const requests: Array<{ command: 'around' | 'latest'; anchor: number | null; navigationVersion: number }> = []; const handle = (generation: string): DesktopTranscriptHandle => ({ ...identity, generation, readThroughMessageId: null, async loadBefore() { assert.fail('an obsolete history request was replayed'); }, async loadAfter() { assert.fail('an obsolete newer request was replayed'); }, async loadAround(anchor, _bytes, navigation) { - requests.push({ generation, anchor, navigation }); - acceptSnapshot(store, navigation?.navigationVersion ?? 0, generation, [record(1)]); + requests.push({ command: 'around', anchor, navigationVersion: navigation.navigationVersion }); + acceptSnapshot(store, navigation.navigationVersion, generation, [record(0)]); + }, + async loadLatest(navigation) { + requests.push({ command: 'latest', anchor: null, navigationVersion: navigation.navigationVersion }); + acceptSnapshot(store, navigation.navigationVersion, generation, [record(1)]); }, async close() {}, }); - let opens = 0; - const controller = createDesktopTranscriptRangeController(store, async () => { - opens += 1; - if (opens === 1) return opening.promise; - // A new preload can initialize and ACK version-zero bootstrap while the - // range store continues showing the last committed view until replay. - acceptSnapshot(store, 0, 'generation-2', [record(0)]); - return handle('generation-2'); - }); + const controller = createDesktopTranscriptRangeController(store, async () => opening.promise); const history = controller.loadAround(0); const latest = controller.loadLatest(); opening.resolve(handle('generation-1')); await Promise.all([history, latest]); - assert.deepEqual(requests.map(({ anchor, navigation }) => [anchor, navigation?.intent, navigation?.navigationVersion]), [[null, 'followTail', 2]]); - await controller.reload(); - assert.deepEqual(requests.map(({ generation, navigation }) => [generation, navigation?.intent, navigation?.navigationVersion]), [ - ['generation-1', 'followTail', 2], ['generation-2', 'followTail', 2], - ]); - assert.equal(store.range().generation, 'generation-2'); + assert.deepEqual(requests, [{ command: 'latest', anchor: null, navigationVersion: 2 }]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); await controller.close(); }); -test('a rejected older navigation cannot fail the newer follow-tail intent', async () => { +test('a rejected older navigation cannot fail the newer latest command', async () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); const historyEntered = deferred(); let rejectHistory!: (error: Error) => void; @@ -217,11 +171,13 @@ test('a rejected older navigation cannot fail the newer follow-tail intent', asy const controller = createDesktopTranscriptRangeController(store, async () => ({ ...identity, readThroughMessageId: null, async loadBefore() {}, async loadAfter() {}, - async loadAround(anchor, _bytes, navigation) { - if (anchor === 0) { - historyEntered.resolve(); - await historyResult; - } else acceptSnapshot(store, navigation!.navigationVersion, identity.generation, [record(1)]); + async loadAround(anchor) { + assert.equal(anchor, 0); + historyEntered.resolve(); + await historyResult; + }, + async loadLatest(navigation) { + acceptSnapshot(store, navigation.navigationVersion, identity.generation, [record(1)]); }, async close() {}, })); @@ -234,7 +190,7 @@ test('a rejected older navigation cannot fail the newer follow-tail intent', asy await controller.close(); }); -test('superseded batches remain ACKable and cannot reset the latest range while delivery drains', { timeout: 10_000 }, async () => { +test('superseded batches remain ACKable and cannot reset the latest window while delivery drains', { timeout: 10_000 }, async () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); const firstOldBatch = deferred(); const eventsClosed = deferred(); @@ -277,13 +233,13 @@ test('superseded batches remain ACKable and cannot reset the latest range while }); const request: DesktopTranscriptRangeRequest = { consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: 128 * 1024, navigationVersion: 1, intent: 'history', + anchorSequence: 0, maxBytes: PAGE_BYTES, navigationVersion: 1, }; store.expectNavigation(1); const history = observer.loadTranscriptAround(request, 1); await firstOldBatch.promise; store.expectNavigation(2); - const following = observer.loadTranscriptAround({ ...request, navigationVersion: 2, intent: 'followTail', anchorSequence: null }, 1); + const following = observer.loadTranscriptLatest({ ...request, navigationVersion: 2, anchorSequence: null }, 1); releaseAcks = true; for (const batch of blocked) ack(batch); await Promise.all([history, following]); @@ -294,75 +250,6 @@ test('superseded batches remain ACKable and cannot reset the latest range while await observer.close(); }); -for (const changedEpoch of [false, true]) { -for (const latest of [false, true]) { -test(`${changedEpoch ? 'cross-epoch' : 'same-Host'} registry recovery admits newer ${latest ? 'latest' : 'Turn reading'} while old replay is pending`, { timeout: 10_000 }, async () => { - const registry = new RuntimeHostSessionObservationRegistry(); - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const replayEntered = deferred(); - const replayRelease = deferred(); - const latestEntered = deferred(); - const calls: Array<{ generation: string; request: DesktopTranscriptRangeRequest }> = []; - const replacementEpoch = changedEpoch ? 'host-2' : 'host-1'; - const makeSource = (generation: string, hostEpoch: string) => ({ - async observe() {}, async unobserve() {}, async closeTranscript() {}, - async openTranscript(sessionId: string) { - return { sessionId, generation, hostEpoch, readThroughMessageId: null }; - }, - async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, - async loadTranscriptAround(request: DesktopTranscriptRangeRequest) { - assert.equal(request.hostEpoch, hostEpoch, 'only the successfully opened source epoch is accepted'); - calls.push({ generation, request }); - if (generation === 'generation-2' && request.navigationVersion === 1) { - replayEntered.resolve(); - await replayRelease.promise; - } - const row = record(request.navigationVersion === 2 ? 2 : 0); - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, generation, hostEpoch, navigationVersion: request.navigationVersion, - durableThrough: 2, durable: [{ sequence: row.identity, message: row.message }], - overlay: [], hasOlder: false, hasNewer: false, - })) store.accept(batch); - if (generation === 'generation-2' && request.navigationVersion === 2) latestEntered.resolve(); - }, - }); - const target = { id: 1, send() {}, once() {}, off() {} }; - const first = makeSource('generation-1', 'host-1'); - await registry.attach(first); - await registry.openTranscript('session-1', 'consumer-1', target); - const request: DesktopTranscriptRangeRequest = { - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: 128 * 1024, navigationVersion: 1, intent: 'history', - }; - store.expectNavigation(1); - await registry.loadTranscriptAround(request, target.id); - registry.detach(first); - await registry.attach(makeSource('generation-2', replacementEpoch)); - await replayEntered.promise; - store.expectNavigation(2); - const next = registry.loadTranscriptAround({ ...request, navigationVersion: 2, - intent: latest ? 'followTail' : 'history', anchorSequence: latest ? null : 2, - readingTurnId: latest ? undefined : 'turn-2', preserveRange: true, - }, target.id); - await latestEntered.promise; - replayRelease.resolve(); - await next; - await flush(); - await registry.loadTranscriptAround(request, target.id); - assert.deepEqual(calls.map(({ generation, request: entry }) => [generation, entry.navigationVersion, entry.intent]), [ - ['generation-1', 1, 'history'], ['generation-2', 1, changedEpoch ? 'followTail' : 'history'], - ['generation-2', 2, latest ? 'followTail' : 'history'], - ]); - assert.equal(calls[2]!.request.hostEpoch, replacementEpoch); - assert.equal(calls[2]!.request.anchorSequence, latest || changedEpoch ? null : 2); - assert.equal(calls[2]!.request.readingTurnId, latest ? undefined : 'turn-2'); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-2'], 'late replay cannot replace the new navigation'); - await assert.rejects(registry.loadTranscriptAround({ ...request, navigationVersion: 3, hostEpoch: 'unrelated-epoch' }, target.id)); - await registry.close(); -}); -} -} - const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; function acceptSnapshot(store: DesktopTranscriptRangeStore, navigationVersion: number, generation: string, records: Array>) { for (const batch of encodeDesktopTranscriptSnapshot({ @@ -390,4 +277,3 @@ function deferred() { const promise = new Promise((complete) => { resolve = complete; }); return { promise, resolve }; } -async function flush() { await new Promise((resolve) => setImmediate(resolve)); } diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts index b56014c5d2..279e9610be 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -34,53 +34,44 @@ import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.j const PAGE_BYTES = 128 * 1024; -test('loading history before a small latest Turn makes an oversized earlier Turn reachable', async () => { +test('a history page reaches an oversized earlier Turn without disturbing the tail', async () => { const fixture = await oversizedHistoryFixture(); try { assert.deepEqual(sequences(fixture.replica), [2, 3]); - await fixture.replica.loadBefore(2, PAGE_BYTES); + const page = await fixture.replica.loadBefore(2, PAGE_BYTES); - assert.equal( - sequences(fixture.replica)[0], - 0, - 'the successfully fetched earlier Turn must survive eviction so history paging makes progress', + assert.ok(page); + assert.deepEqual(page.durable.map(({ sequence }) => sequence), [0, 1]); + assert.equal(page.durable[1]?.message.id, 'assistant-a', + 'the oversized earlier answer reaches the Renderer whole'); + assert.equal(page.hasOlder, false); + assert.deepEqual( + sequences(fixture.replica), + [2, 3], + 'a window read answers the Renderer and leaves the Main tail alone', ); - assert.equal(fixture.replica.snapshot().hasOlder, false); - assert.equal(fixture.replica.messages()[1]?.id, 'assistant-a'); - - await fixture.replica.readAt(0); - - assert.deepEqual(sequences(fixture.replica), [0, 1]); - assert.equal(fixture.replica.snapshot().hasNewer, true); } finally { fixture.replica.close(); } }); -test('durable tail advancement preserves an explicitly selected oversized history Turn', async () => { +test('tail catch-up evicts only the oldest Turns and always keeps the newest complete', async () => { const fixture = await oversizedHistoryFixture(); try { - await fixture.replica.loadAround(0, PAGE_BYTES); - assert.deepEqual(sequences(fixture.replica), [0, 1]); - fixture.requests.length = 0; - await fixture.replica.advance(4); + assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); + + await fixture.replica.advance(6); assert.deepEqual( sequences(fixture.replica), - [0, 1], - 'persisting a new answer must not replace the history range selected by the reader', + [5, 6], + 'the oversized newest Turn stays whole and the older Turn leaves the tail', ); - assert.equal(fixture.replica.durableThrough, 4); - assert.equal(fixture.replica.snapshot().hasNewer, true); - assert.deepEqual(fixture.requests, [], 'history ownership only advances the durable watermark'); - - await fixture.replica.followLatest(PAGE_BYTES); - - assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); - assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-b-later'); - assert.equal(fixture.replica.snapshot().hasNewer, false); + assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-c'); + assert.equal(fixture.replica.durableThrough, 6); + assert.equal(fixture.replica.snapshot().hasOlder, true); } finally { fixture.replica.close(); } @@ -91,8 +82,6 @@ test('a completed resident bookmark does not reload after streaming settlement e const lifecycle = createTranscriptRestoreLifecycle(); let loaded = 0; const controller = { - // This test isolates restore command lifetime from reader navigation. - setReadingAnchor: async () => {}, loadAround: async (sequence: number) => { loaded += 1; await fixture.replica.loadAround(sequence, PAGE_BYTES); @@ -138,7 +127,6 @@ test('a completed resident bookmark does not reload after streaming settlement e test('reopening a bookmark at the current Turn retains content persisted later in that same Turn', async () => { const fixture = await oversizedHistoryFixture(); try { - await fixture.replica.readAt(2); assert.deepEqual(sequences(fixture.replica), [2, 3]); await fixture.replica.advance(4); assert.equal(fixture.replica.durableThrough, 4, 'the Host has persisted the final answer segment'); @@ -150,8 +138,7 @@ test('reopening a bookmark at the current Turn retains content persisted later i sessionId: 'session-1', readingAnchor: { turnId: 'turn-b', sequence: 2 }, controller: { - setReadingAnchor: (sequence) => fixture.replica.readAt(sequence), - loadAround: (sequence) => fixture.replica.loadAround(sequence, PAGE_BYTES), + loadAround: async (sequence) => { await fixture.replica.loadAround(sequence, PAGE_BYTES); }, store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), @@ -177,53 +164,6 @@ test('reopening a bookmark at the current Turn retains content persisted later i } }); -test('an oversized new Turn cannot evict the current Turn being read while its own answer finishes', async () => { - const fixture = await oversizedHistoryFixture(); - try { - await fixture.replica.readAt(2); - await fixture.replica.advance(4); - await fixture.replica.advance(6); - - assert.deepEqual( - sequences(fixture.replica), - [2, 3, 4], - 'the reader keeps the complete selected Turn B when a new oversized Turn C is persisted', - ); - assert.equal(fixture.replica.durableThrough, 6); - assert.equal(fixture.replica.snapshot().hasNewer, true); - assert.equal(fixture.replica.messages().some(({ id }) => id === 'assistant-c'), false); - - await fixture.replica.followLatest(PAGE_BYTES); - - assert.deepEqual(sequences(fixture.replica), [5, 6]); - assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-c'); - assert.equal(fixture.replica.snapshot().hasNewer, false); - } finally { - fixture.replica.close(); - } -}); - -test('streaming persistence retains a newly loaded oversized neighbor until the reader chooses an anchor', async () => { - const fixture = await oversizedHistoryFixture(); - try { - await fixture.replica.loadBefore(2, PAGE_BYTES); - assert.deepEqual(sequences(fixture.replica), [0, 1, 2, 3]); - - await fixture.replica.advance(4); - - assert.deepEqual( - sequences(fixture.replica), - [0, 1, 2, 3, 4], - 'new durable text in B must not erase the older A that the reader just requested', - ); - await fixture.replica.readAt(2); - assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); - assert.equal(fixture.replica.snapshot().hasOlder, true); - } finally { - fixture.replica.close(); - } -}); - test('repeated message notifications share one pending restore and cancellation preserves the newer bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); let finishLoad!: () => void; diff --git a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts index c2d53b6873..c1d56a1a2b 100644 --- a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts @@ -19,11 +19,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { deferred } from '@maka/core/test-only/async-primitives'; import { markPersisted } from '@maka/core/persisted-value'; import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionTranscriptPage, type SessionTranscriptPageInput, } from '@maka/runtime-host/protocol'; import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; @@ -32,8 +32,7 @@ import { readSessionTranscriptPage, updateSubscriberTranscriptHighWater, } from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; -import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import type { DesktopTranscriptNavigation } from '../../preload/transcript-contract.js'; +import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; @@ -48,38 +47,65 @@ const B_COMPLETED_THROUGH = 'completed-b'; const C_COMPLETED_THROUGH = 'completed-c'; for (const coalesced of [false, true]) { - test(`settles a bootstrap overlay outside history through ${coalesced ? 'a coalesced B+C watermark' : 'separate B and C watermarks'}`, async () => { + test(`settles a bootstrap overlay through ${coalesced ? 'a coalesced B+C watermark' : 'separate B and C watermarks'}`, async () => { const fixture = await openFixture(); try { - const { replica, renderer, changes } = fixture; + const { replica, renderer } = fixture; assert.equal(replica.snapshot().overlay.find(({ id }) => id === 'answer-b')?.id, 'answer-b'); - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); - assertHistoryRange(fixture); - const before = changes.length; if (!coalesced) { await fixture.advance(B_COMPLETED_THROUGH); - assertHistoryRange(fixture); assert.deepEqual(replica.snapshot().overlay, []); } await fixture.advance(C_COMPLETED_THROUGH); - assertHistoryRange(fixture); assert.equal(replica.durableThrough, fixture.watermark(C_COMPLETED_THROUGH)); assert.deepEqual(replica.snapshot().overlay, []); - assert.deepEqual(changes.slice(before).flatMap((change) => change.completedOverlayMessageIds), ['user-b', 'answer-b']); - assert.ok(changes.slice(before).every((change) => change.durableUpserts.length === 0)); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-a', 'answer-a', 'completed-a']); - - await replica.followLatest(PAGE_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); - assert.equal(replica.snapshot().hasNewer, false); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-c', 'answer-c', 'completed-c']); + assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), + ['user-c', 'answer-c', 'completed-c']); + assert.equal( + renderer.snapshot().messages.some((message) => message.type === 'assistant' && message.text === 'B partial'), + false, + 'the durable row replaced the partial overlay answer', + ); + assert.ok(renderer.snapshot().messages.some(({ id }) => id === 'answer-c')); } finally { await fixture.close(); } }); } +test('a window parked off the tail keeps the Turn it is reading when that Turn completes', async () => { + const fixture = await openFixture(); + try { + const { replica, renderer } = fixture; + // Reading history: the window dropped the newest rows to meet its budget, + // so its newer edge is a gap and tail growth is no longer its business. + const oldest = renderer.range().oldestSequence; + renderer.retain(oldest, oldest); + assert.equal(renderer.range().hasNewer, true); + const overlaid = renderer.snapshot().messages.find(({ id }) => id === 'answer-b'); + assert.equal(overlaid?.type === 'assistant' ? overlaid.text : undefined, 'B partial'); + + await fixture.advance(B_COMPLETED_THROUGH); + await fixture.advance(C_COMPLETED_THROUGH); + + assert.deepEqual( + renderer.snapshot().messages.flatMap((message) => + message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), + ['B partial and completed answer'], + 'the completed body replaces the overlay instead of both disappearing', + ); + assert.deepEqual( + renderer.snapshot().messages.flatMap(({ id, turnId }) => turnId === 'c' ? [id] : []), + [], + 'settling the read Turn does not pull the window forward to a later Turn', + ); + assert.equal(replica.snapshot().overlay.length, 0); + } finally { + await fixture.close(); + } +}); + test('a completed live answer remains unique after a fresh transcript subscription', async () => { const fixture = await openFixture(); let reopened: Awaited> | undefined; @@ -107,221 +133,109 @@ test('a completed live answer remains unique after a fresh transcript subscripti } }); -test('retains an unfinished overlay through runtime checkpoints and skips scans after settlement', async () => { +test('retains an unfinished overlay through runtime checkpoints', async () => { const fixture = await openFixture(); try { - const { replica, changes, requests } = fixture; - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); + const { replica, renderer } = fixture; await fixture.advance(B_STEERING_THROUGH); assert.equal(replica.durableThrough, fixture.bootstrapThrough, 'running B has no durable ending yet'); - assertHistoryRange(fixture); const unfinished = replica.snapshot().overlay.find(({ id }) => id === 'answer-b'); assert.equal(unfinished?.type === 'assistant' ? unfinished.text : undefined, 'B partial'); - assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), []); await fixture.advance(B_COMPLETED_THROUGH); - assert.deepEqual(replica.snapshot().overlay, []); - const before = requests.length; - await fixture.advance(C_COMPLETED_THROUGH); - assert.equal(requests.length, before, 'history with no pending overlay needs no durable page read'); - assertHistoryRange(fixture); + assert.deepEqual( + replica.snapshot().overlay, + [], + 'the Turn ending retires exactly the overlay rows it made durable', + ); + assert.deepEqual( + renderer.snapshot().messages.flatMap((message) => + message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), + ['B partial and completed answer'], + ); } finally { await fixture.close(); } }); -test('a latest range jump settles skipped overlays without waiting for another advance', async () => { +test('catch-up retires the completed overlay in one notification', async () => { const fixture = await openFixture(); try { - const { replica } = fixture; - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); - await fixture.announce(C_COMPLETED_THROUGH); - // Both commands are queued synchronously. The range jump owns the newer - // navigation before the queued catch-up starts handling the watermark. - const latest = replica.followLatest(PAGE_BYTES); - const advance = replica.advance(fixture.watermark(C_COMPLETED_THROUGH)); - await latest; + const { replica, changes, requests } = fixture; + const before = requests.length; + await fixture.advance(B_COMPLETED_THROUGH); + const settled = changes.filter((change) => + change.durableUpserts.some(({ message }) => message.id === 'answer-b')); + assert.equal(settled.length, 1); + const answer = replica.messages().find(({ id }) => id === 'answer-b'); + assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); + assert.equal(requests.length - before, 1, 'catch-up settles through the same page it installs'); assert.deepEqual(replica.snapshot().overlay, []); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); - await advance; } finally { await fixture.close(); } }); -for (const intent of ['followTail', 'history'] as const) { - test(`the ${intent} range retires the completed overlay in one notification`, async () => { - const fixture = await openFixture(); - try { - const { replica, changes, requests } = fixture; - if (intent === 'history') await replica.readAt(fixture.history[0]!.sequence); - const before = requests.length; - await fixture.advance(B_COMPLETED_THROUGH); - const settled = changes.filter((change) => change.completedOverlayMessageIds.includes('answer-b')); - assert.equal(settled.length, 1); - if (intent === 'followTail') { - assert.ok(settled[0]!.durableUpserts.some(({ message }) => message.id === 'answer-b')); - const answer = replica.messages().find(({ id }) => id === 'answer-b'); - assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); - } else { - assertHistoryRange(fixture); - assert.equal(settled[0]!.durableUpserts.length, 0, 'settlement preserves the selected oversized history Turn'); - } - assert.equal(requests.length - before, 1, 'normal catch-up settles through the same page it installs'); - assert.deepEqual(replica.snapshot().overlay, []); - } finally { - await fixture.close(); - } - }); -} - -for (const coalesced of [false, true]) { - test(`reading an overlay-only B survives ${coalesced ? 'coalesced B+C completion' : 'B completion followed by oversized C'}`, async () => { - const fixture = await openFixture(); - const { replica, renderer } = fixture; - const navigations: Array<{ anchor: number | null; navigation: DesktopTranscriptNavigation }> = []; - // Only the process boundary is in-process here: controller invalidation, - // replica ownership, Host cursors, SQLite ledger, and renderer batches all - // use their production implementations. - const controller = createDesktopTranscriptRangeController(renderer, async () => ({ - sessionId: replica.sessionId, generation: replica.generation, - hostEpoch: replica.hostEpoch, readThroughMessageId: null, - async loadBefore(anchor, maxBytes = PAGE_BYTES, navigation) { - assert.ok(navigation); - fixture.acceptNavigation(navigation); - await replica.loadBefore(anchor, maxBytes, replica.setNavigation(navigation.intent)); - }, - async loadAfter(anchor, maxBytes = PAGE_BYTES, navigation) { - assert.ok(navigation); - fixture.acceptNavigation(navigation); - await replica.loadAfter(anchor, maxBytes, replica.setNavigation(navigation.intent)); - }, - async loadAround(anchor, maxBytes = PAGE_BYTES, navigation) { - assert.ok(navigation); - navigations.push({ anchor, navigation }); - fixture.acceptNavigation(navigation); - const token = replica.setNavigation(navigation.intent); - if (navigation.preserveRange) await replica.readAt(anchor, token, navigation.readingTurnId); - else if (navigation.intent === 'followTail') await replica.followLatest(maxBytes, token); - else { - assert.notEqual(anchor, null); - await replica.loadAround(anchor!, maxBytes, token); - } - }, - async close() {}, - })); - try { - await controller.ready(); - assert.equal(renderer.sequenceForTurn('b'), null, - 'a running ledger invocation has no durable user sequence to use as a bookmark'); - assert.ok(renderer.snapshot().messages.some(({ id }) => id === 'answer-b')); - await controller.setReadingAnchor(renderer.sequenceForTurn('b'), 'b'); - assert.equal(navigations[0]?.anchor, null, 'the old A sequence cannot impersonate B'); - assert.equal(navigations[0]?.navigation.readingTurnId, 'b'); - assert.equal(navigations[0]?.navigation.intent, 'history'); - - const expectedB = ['user-b', 'steering-b', 'answer-b', 'completed-b']; - if (!coalesced) { - await fixture.advance(B_COMPLETED_THROUGH); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), expectedB); - assert.ok(renderer.sequenceForTurn('b') !== null, 'the selected Turn now resolves to its own durable sequence'); - } - await fixture.advance(C_COMPLETED_THROUGH); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), expectedB); - assert.deepEqual(replica.snapshot().overlay, []); - assert.equal(replica.snapshot().hasNewer, true); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), expectedB); - const answer = renderer.snapshot().messages.find(({ id }) => id === 'answer-b'); - assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); - assert.equal(renderer.snapshot().messages.some(({ turnId }) => turnId === 'c'), false, - 'finishing C cannot replace the reader-selected B range'); - - await controller.loadLatest(); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-c', 'answer-c', 'completed-c']); - assert.equal(replica.snapshot().hasNewer, false); - } finally { - await controller.close(); - await fixture.close(); - } +test('a window page read retires the tail overlay copy without notifying other windows', async () => { + const older: StoredMessage = { + type: 'assistant', id: 'answer-older', turnId: 'older', ts: 1, + text: 'Older answer', modelId: 'fixture-model', + }; + const newest: StoredMessage = { + type: 'assistant', id: 'answer-newest', turnId: 'newest', ts: 2, + text: 'Newest answer', modelId: 'fixture-model', + }; + const durablePage = (): SessionTranscriptPage => ({ + kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', + throughSequence: 2, rawBytes: 1, fragments: [], rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }); -} - -test('a fresh replica restores B from an overlay-only bookmark after oversized C owns the tail', async () => { - const fixture = await openFixture(); - let reopened: Awaited> | undefined; + const bootstrap = durablePage(); + const decoded = new Map; + nextCursor: string | null; + }>([[bootstrap, { messages: [{ identity: 2, message: newest }], nextCursor: null }]]); + const changes: DesktopTranscriptReplicaChange[] = []; + const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId: 'session-1', metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn: null, goal: null, + queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 2, overlayMessageCount: 1, + durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + }, + // The Host was still streaming the older answer when the subscription + // opened, so bootstrap holds an overlay copy of an already durable row. + loadTranscriptOverlay: async () => [older], + loadTranscriptPage: async () => { + const page = durablePage(); + decoded.set(page, { messages: [{ identity: 1, message: older }], nextCursor: null }); + return page; + }, + decodeTranscriptPage: async (page) => decoded.get(page)!, + async close() {}, + }), { onChange: (_replica, change) => changes.push(change) }); try { - const bookmark = { turnId: 'b', sequence: fixture.renderer.sequenceForTurn('b') }; - assert.equal(bookmark.sequence, null); - await fixture.replica.readAt(bookmark.sequence, undefined, bookmark.turnId); - await fixture.advance(B_COMPLETED_THROUGH); - await fixture.advance(C_COMPLETED_THROUGH); + assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), ['answer-older']); - reopened = await openSettledReplica(fixture.ledger); - assert.deepEqual(reopened.replica.snapshot().durable.map(({ message }) => message.id), - ['user-c', 'answer-c', 'completed-c']); - assert.deepEqual(reopened.replica.snapshot().overlay, []); - const before = reopened.requests.length; - await reopened.replica.readAt(bookmark.sequence, undefined, bookmark.turnId); - assert.deepEqual(reopened.replica.snapshot().durable.map(({ message }) => message.id), - ['user-b', 'steering-b', 'answer-b', 'completed-b']); - assert.ok(reopened.requests.slice(before).some((request) => request.direction === 'older'), - 'a no-sequence bookmark finds its durable Turn through the real bounded pager'); - assert.ok(reopened.requests.slice(before).every((request) => request.maxBytes <= 512 * 1024)); - assert.equal(reopened.replica.snapshot().hasNewer, true); - await reopened.replica.advance(fixture.watermark(C_COMPLETED_THROUGH)); - assert.deepEqual(new Set(reopened.replica.snapshot().durable.map(({ message }) => message.turnId)), new Set(['b'])); - } finally { - await reopened?.close(); - await fixture.close(); - } -}); + const page = await replica.loadBefore(2, PAGE_BYTES); -test('superseded settlement pages cannot retire overlays or skip the current navigation retry', async () => { - const firstStarted = deferred(); - const releaseFirst = deferred(); - const secondStarted = deferred(); - const releaseSecond = deferred(); - let settlementReads = 0; - const fixture = await openFixture(async (request) => { - if (request.direction !== 'newer' || request.anchorSequence !== fixture.bootstrapThrough) return; - settlementReads += 1; - if (settlementReads === 1) { - firstStarted.resolve(); - await releaseFirst.promise; - } else if (settlementReads === 2) { - secondStarted.resolve(); - await releaseSecond.promise; - } - }); - try { - const { replica, changes } = fixture; - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); - const advance = fixture.advance(C_COMPLETED_THROUGH); - await firstStarted.promise; - const latest = replica.followLatest(PAGE_BYTES); - releaseFirst.resolve(); - await secondStarted.promise; - assert.equal(replica.snapshot().overlay.find(({ id }) => id === 'answer-b')?.id, 'answer-b'); - assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), []); - releaseSecond.resolve(); - await latest; - await advance; - assert.equal(settlementReads, 2, 'the new command retries from the last actually checked overlay watermark'); - assert.deepEqual(replica.snapshot().overlay, []); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); - assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), ['user-b', 'answer-b']); + assert.ok(page); + assert.deepEqual(page.durable.map(({ message }) => message.id), ['answer-older']); + assert.deepEqual(replica.snapshot().overlay, [], 'the durable row retires the tail overlay copy'); + assert.deepEqual(changes, [], 'a window page changes nothing another window holds'); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2]); } finally { - releaseFirst.resolve(); - releaseSecond.resolve(); - await fixture.close(); + replica.close(); } }); -function assertHistoryRange(fixture: Awaited>): void { - assert.deepEqual(fixture.replica.snapshot().durable, fixture.history); - assert.equal(fixture.replica.snapshot().hasNewer, fixture.replica.durableThrough! > fixture.bootstrapThrough); -} - async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => Promise) { const messages: StoredMessage[] = [ user('a'), assistant('a', 'A'.repeat(600 * 1024)), turnState('a', 'completed'), @@ -361,7 +275,6 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }); const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); const changes: DesktopTranscriptReplicaChange[] = []; - let navigationVersion = 0; const renderer = new DesktopTranscriptRangeStore(JSON.stringify(['local', sessionId])); const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: subscription.snapshot, activeAssistantStreams, events: subscription, @@ -375,7 +288,13 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }), { onChange: (current, change) => { changes.push(change); - for (const batch of encodeDesktopTranscriptChange({ ...current.snapshot(), navigationVersion }, change)) renderer.accept(batch); + // Tail growth is broadcast to every consumer and carries no navigation. + const identity = { + sessionId: current.sessionId, + generation: current.generation, + hostEpoch: current.hostEpoch, + }; + for (const batch of encodeDesktopTranscriptChange(identity, change)) renderer.accept(batch); }, }); for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) renderer.accept(batch); @@ -401,7 +320,6 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }; return { replica, renderer, changes, requests, announce, history, bootstrapThrough, ledger, - acceptNavigation: (navigation: DesktopTranscriptNavigation) => { navigationVersion = navigation.navigationVersion; }, watermark: (checkpoint: string) => { const value = watermarks.get(checkpoint); assert.notEqual(value, undefined); @@ -409,7 +327,7 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }, async advance(messageId: string) { await announce(messageId); - await replica.advance(watermarks.get(messageId)!); + await replica.advance(watermarks.get(messageId) ?? bootstrapThrough); }, async close() { replica.close(); diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 8b16db2505..20c098259d 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -47,20 +47,22 @@ test('sending before transcript open completes supersedes the queued bookmark wi const controller = createDesktopTranscriptRangeController(store, () => opening.promise); const lifecycle = createTranscriptRestoreLifecycle(); const requests: Array<{ sequence: number | null; navigation?: DesktopTranscriptNavigation }> = []; + const publish = (sequence: number | null, navigation: DesktopTranscriptNavigation) => { + requests.push({ sequence, navigation }); + const turnId = sequence === null ? 'b' : 'a'; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + navigationVersion: navigation.navigationVersion, durableThrough: 20, + durable: [{ sequence: sequence ?? 20, message: { + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, + })) store.accept(batch); + }; const handle: DesktopTranscriptHandle = { sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { - requests.push({ sequence, navigation }); - const turnId = sequence === null ? 'b' : 'a'; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - navigationVersion: navigation!.navigationVersion, durableThrough: 20, - durable: [{ sequence: sequence ?? 20, message: { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, - })) store.accept(batch); - }, + async loadAround(sequence, _maxBytes, navigation) { publish(sequence, navigation); }, + async loadLatest(navigation) { publish(null, navigation); }, }; const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId, controller, readingAnchor: { turnId: 'a', sequence: 10 }, @@ -83,7 +85,7 @@ test('sending before transcript open completes supersedes the queued bookmark wi restore(); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(requests.map(({ sequence, navigation }) => - [sequence, navigation?.intent, navigation?.navigationVersion]), [[null, 'followTail', 2]]); + [sequence, navigation?.navigationVersion]), [[null, 2]]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['answer-b']); const latest = store.snapshot(); for (const batch of encodeDesktopTranscriptSnapshot({ @@ -100,100 +102,21 @@ test('sending before transcript open completes supersedes the queued bookmark wi } }); -test('a resident search supersedes send catch-up before its older latest response can evict the target', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const latestStarted = deferred(); - const latestFinished = deferred(); - const releaseLatest = deferred(); - const readingAdmitted = deferred(); - const admissions: Array<{ sequence: number | null; version: number; preserveRange?: boolean }> = []; - const publish = (turnId: string, sequence: number, navigationVersion: number) => { - const message: StoredMessage = { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion, - durableThrough: 20, durable: [{ sequence, message }], overlay: [], hasOlder: false, hasNewer: turnId === 'a', - })) store.accept(batch); - }; - publish('a', 10, 0); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - loadBefore: async () => {}, loadAfter: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { - const version = navigation!.navigationVersion; - admissions.push({ sequence, version, preserveRange: navigation!.preserveRange }); - if (sequence === null) { - latestStarted.resolve(); - await releaseLatest.promise; - publish('b', 20, version); - latestFinished.resolve(); - } else { - publish('a', 10, version); - readingAdmitted.resolve(); - } - }, - close: async () => {}, - })); - try { - const lifecycle = createTranscriptRestoreLifecycle(); - const sessionUi = createAppShellSessionUiStateController(); - assert.equal(await prepareTranscriptForSend({ - sessionId, currentSessionId: { current: sessionId }, controller: { current: controller }, - cancel: (sessionId) => lifecycle.cancel(sessionId), - followLatest: sessionUi.transcriptViewportNavigation.followLatest, - }), true, 'local admission must not wait for the latest range'); - await latestStarted.promise; - const restore = () => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, - searchTarget: { sessionId, turnId: 'a', sequence: 10, nonce: 1 }, - isCurrent: () => true, setReadingAnchor: () => {}, - onError: (error) => assert.fail(String(error)), - }); - restore(); - await readingAdmitted.promise; - releaseLatest.resolve(); - await latestFinished.promise; - await new Promise((resolve) => setImmediate(resolve)); - restore(); - assert.deepEqual(admissions, [ - { sequence: null, version: 1, preserveRange: undefined }, - { sequence: 10, version: 2, preserveRange: true }, - ]); - assert.equal(store.sequenceForTurn('a'), 10); - assert.equal(store.sequenceForTurn('b'), null); - assert.deepEqual(store.snapshot().messages.map((message) => message.turnId), ['a']); - } finally { - releaseLatest.resolve(); - await controller.close(); - } -}); - -for (const source of ['bootstrap overlay', 'live projection'] as const) { -test(`a ${source} bookmark admits its Turn once and retains it across range reload`, async () => { +test('an overlay-only bookmark stays available without loading another range', async () => { const sessionId = JSON.stringify(['host-1', 'session-1']); const store = new DesktopTranscriptRangeStore(sessionId); const overlay: StoredMessage = { type: 'assistant', id: 'answer-b', turnId: 'b', text: 'partial B', ts: 1, modelId: 'fixture', }; - const publish = (navigationVersion: number) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion, - durableThrough: null, durable: [], overlay: source === 'bootstrap overlay' ? [overlay] : [], - hasOlder: false, hasNewer: false, - })) store.accept(batch); - }; - publish(0); - const admissions: Array<{ sequence: number | null; turnId?: string; version: number; preserveRange?: boolean }> = []; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion: 0, + durableThrough: null, durable: [], overlay: [overlay], hasOlder: false, hasNewer: false, + })) store.accept(batch); const controller = createDesktopTranscriptRangeController(store, async () => ({ sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { - admissions.push({ sequence, turnId: navigation!.readingTurnId, - version: navigation!.navigationVersion, preserveRange: navigation!.preserveRange }); - publish(navigation!.navigationVersion); - }, + loadAround: async () => assert.fail('an overlay-only bookmark has no page to load'), + loadLatest: async () => assert.fail('an overlay-only bookmark has no page to load'), })); const lifecycle = createTranscriptRestoreLifecycle(); let unavailable = 0; @@ -201,8 +124,6 @@ test(`a ${source} bookmark admits its Turn once and retains it across range relo const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId, controller, readingAnchor: { turnId: 'b' }, isCurrent: () => true, - isLiveTurn: (candidateSessionId, turnId) => source === 'live projection' && - candidateSessionId === sessionId && turnId === 'b', setReadingAnchor: (_sessionId, anchor) => { if (!anchor) cleared += 1; }, onRestoreUnavailable: () => { unavailable += 1; }, onError: (error) => assert.fail(String(error)), }); @@ -213,155 +134,79 @@ test(`a ${source} bookmark admits its Turn once and retains it across range relo assert.equal(store.sequenceForTurn('b'), null); assert.equal(unavailable, 0); assert.equal(cleared, 0); - assert.deepEqual(admissions, [{ sequence: null, turnId: 'b', version: 1, preserveRange: true }]); - await controller.reload(); - assert.deepEqual(admissions[1], { sequence: null, turnId: 'b', version: 1, preserveRange: false }); - } finally { - await controller.close(); - } -}); -} - -test('both paging directions retain an overlay-only Turn identity and admit a fresh intent', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const message = (turnId: string): StoredMessage => ({ - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - }); - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion: 0, - durableThrough: 40, durable: [{ sequence: 10, message: message('a') }], - overlay: [message('b')], hasOlder: true, hasNewer: true, - })) store.accept(batch); - const calls: Array<{ operation: string; sequence: number | null; turnId?: string; version?: number }> = []; - const record = (operation: string) => async (sequence: number | null, _maxBytes?: number, - navigation?: import('../../preload/transcript-contract.js').DesktopTranscriptNavigation) => { - calls.push({ operation, sequence, turnId: navigation?.readingTurnId, version: navigation?.navigationVersion }); - }; - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - loadBefore: record('before'), loadAfter: record('after'), loadAround: record('around'), close: async () => {}, - })); - try { - await controller.setReadingAnchor(null, 'b'); - await controller.loadBefore(undefined, 'b'); - await controller.loadAfter(undefined, 'b'); - await controller.loadBefore(undefined, 'a'); - await controller.loadAfter(undefined, 'a'); - assert.deepEqual(calls, [ - { operation: 'around', sequence: null, turnId: 'b', version: 1 }, - { operation: 'around', sequence: null, turnId: 'b', version: 2 }, - { operation: 'around', sequence: null, turnId: 'b', version: 3 }, - { operation: 'before', sequence: 10, turnId: 'a', version: 4 }, - { operation: 'after', sequence: 10, turnId: 'a', version: 5 }, - ]); } finally { await controller.close(); } }); -test('returning to latest supersedes pending history without letting its completion clear the new pending state', async () => { +test('a history load holds its pending state until the page settles', async () => { const fixture = controllerFixture(); const older = deferred(); - const latest = deferred(); const calls: string[] = []; fixture.controller.loadBefore = async () => { calls.push('older'); await older.promise; }; - fixture.controller.loadLatest = async () => { calls.push('latest'); await latest.promise; }; await fixture.render(); - const loadingOlder = fixture.commands.current!.loadHistory('earlier'); - await fixture.commands.current!.loadHistory('earlier'); - const loadingLatest = fixture.commands.current!.loadHistory('latest'); - assert.deepEqual(calls, ['older', 'latest']); - - older.reject(new Error('superseded history request failed')); - await loadingOlder; + const loading = fixture.commands.current!.loadHistory('earlier'); + assert.deepEqual(calls, ['older']); assert.equal(fixture.pending(), 'session-1'); - latest.resolve(); - await loadingLatest; - assert.equal(fixture.pending(), undefined); -}); - -test('a new Session can load history while the previous Session request is still pending', async () => { - const fixture = controllerFixture(); - const first = deferred(); - const second = deferred(); - fixture.controller.loadBefore = () => first.promise; - await fixture.render(); - const loadingFirst = fixture.commands.current!.loadHistory('earlier'); - - const secondController = { ...fixture.controller, - store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, - loadBefore: () => second.promise, - }; - fixture.props.currentSessionId.current = 'session-2'; - fixture.props.rangeController.current = secondController; - fixture.props.sessionId = 'session-2'; - await fixture.render(); - const loadingSecond = fixture.commands.current!.loadHistory('earlier'); - assert.equal(fixture.pending(), 'session-2'); - - first.resolve(); - await loadingFirst; - assert.equal(fixture.pending(), 'session-2'); - second.resolve(); - await loadingSecond; + older.resolve(); + await loading; assert.equal(fixture.pending(), undefined); }); -test('a new earlier request supersedes pending return-to-latest navigation', async () => { +test('a failed history load reports to its own Session and clears its pending state', async () => { const fixture = controllerFixture(); - const latest = deferred(); - const earlier = deferred(); - const calls: string[] = []; - fixture.controller.loadLatest = async () => { calls.push('latest'); await latest.promise; }; - fixture.controller.loadBefore = async () => { calls.push('earlier'); await earlier.promise; }; + const errors: string[] = []; + fixture.props.onNavigationError = (error) => { errors.push(String(error)); }; + fixture.controller.loadAfter = async () => { throw new Error('later read failed'); }; await fixture.render(); - const loadingLatest = fixture.commands.current!.loadHistory('latest'); - const loadingEarlier = fixture.commands.current!.loadHistory('earlier'); - assert.deepEqual(calls, ['latest', 'earlier']); - latest.resolve(); - await loadingLatest; - assert.equal(fixture.pending(), 'session-1'); - earlier.resolve(); - await loadingEarlier; + await fixture.commands.current!.loadHistory('later'); + assert.deepEqual(errors, ['Error: later read failed']); assert.equal(fixture.pending(), undefined); }); -test('an old Session controller cannot clear pending history after returning to the same Session', async () => { +test('an old Session history load cannot report or clear the new Session state', async () => { const fixture = controllerFixture(); const first = deferred(); - const replacement = deferred(); + fixture.props.onNavigationError = () => assert.fail('a superseded Session must not report'); fixture.controller.loadBefore = () => first.promise; await fixture.render(); const loadingFirst = fixture.commands.current!.loadHistory('earlier'); + assert.equal(fixture.pending(), 'session-1'); fixture.props.currentSessionId.current = 'session-2'; fixture.props.sessionId = 'session-2'; fixture.props.rangeController.current = { ...fixture.controller, store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, + loadBefore: async () => {}, }; await fixture.render(); - fixture.props.currentSessionId.current = 'session-1'; - fixture.props.sessionId = 'session-1'; - fixture.props.rangeController.current = { - ...fixture.controller, - loadBefore: () => replacement.promise, - }; - await fixture.render(); - const loadingReplacement = fixture.commands.current!.loadHistory('earlier'); - assert.equal(fixture.pending(), 'session-1'); + const loadingSecond = fixture.commands.current!.loadHistory('earlier'); + await loadingSecond; + assert.equal(fixture.pending(), undefined); - first.resolve(); + first.reject(new Error('superseded history request failed')); await loadingFirst; - assert.equal(fixture.pending(), 'session-1'); - replacement.resolve(); - await loadingReplacement; assert.equal(fixture.pending(), undefined); }); +test('retaining the reader window trims the store to the visible Turns', async () => { + const fixture = controllerFixture(); + const retained: Array<[number | null, number | null]> = []; + fixture.controller.store.sequenceForTurn = (turnId: string, edge?: 'first' | 'last') => + turnId === 'first' ? 10 : turnId === 'last' ? (edge === 'last' ? 21 : 20) : null; + fixture.controller.store.retain = (oldest, newest) => { + retained.push([oldest, newest]); + return true; + }; + await fixture.render(); + + fixture.commands.current!.retainWindow({ firstTurnId: 'first', lastTurnId: 'last' }); + assert.deepEqual(retained, [[10, 21]]); +}); + function controllerFixture() { const { root } = installReactRenderer(); const commands = createRef(); @@ -370,11 +215,11 @@ function controllerFixture() { loadBefore: async () => {}, loadAfter: async () => {}, loadLatest: async () => {}, - setReadingAnchor: async () => {}, store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), - sequenceForTurn: () => null, + retain: (_oldest: number | null, _newest: number | null) => false, + sequenceForTurn: (_turnId: string, _edge?: 'first' | 'last'): number | null => null, newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), }, @@ -393,7 +238,6 @@ function controllerFixture() { setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), setHistoryPending: (next) => { pending = typeof next === 'function' ? next(pending) : next; }, - historyPageBytes: 512 * 1024, onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), }; diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index 78353d503f..883f6eba64 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -69,18 +69,15 @@ test('preparing a send follows the new prompt and streaming growth, then lets th assert.equal(fixture.scroller.scrollTop, 500); }); -test('reading a live Turn without a durable sequence preserves its Turn identity', async () => { +test('reading a live Turn without a durable sequence bookmarks its Turn identity', async () => { const fixture = viewportFixture(); const sequenceForTurn = fixture.controller.store.sequenceForTurn; fixture.controller.store.sequenceForTurn = (turnId) => turnId === 'latest' ? null : sequenceForTurn(turnId); - const readingCalls: Array<{ sequence: number | null; turnId?: string }> = []; - fixture.controller.setReadingAnchor = async (sequence, turnId) => { readingCalls.push({ sequence, turnId }); }; await fixture.render(); await fixture.readAt(1900); assert.equal(fixture.pinned(), false); assert.deepEqual(fixture.sessionUi.transcriptReadingAnchorBySessionRef.current['session-a'], { turnId: 'latest' }); - assert.deepEqual(readingCalls, [{ sequence: null, turnId: 'latest' }]); }); test('a send is accepted before latest history loads and its old completion cannot move a new Session viewport', async () => { @@ -160,15 +157,12 @@ test('geometry changes from the latest range do not cancel the background load o fixture.controller.loadLatest = () => latest.promise; await fixture.render(); await fixture.readAt(1000); - const readingCalls: Array = []; - fixture.controller.setReadingAnchor = async (sequence: number | null) => { readingCalls.push(sequence); }; await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); await fixture.replaceRangeFromHost(); await act(async () => { latest.resolve(); }); assert.deepEqual(fixture.visibleTurns(), ['latest-b']); - assert.deepEqual(readingCalls, [], 'content geometry must not install a new history reading intent'); assert.equal(fixture.pinned(), true); await fixture.append('new-question', 200); assert.equal(fixture.scroller.scrollTop, 400); @@ -250,10 +244,11 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { let reads = 0; const controller = { loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, - loadLatest: async () => { reads += 1; }, setReadingAnchor: async (_sequence: number | null, _turnId?: string) => {}, + loadLatest: async () => { reads += 1; }, store: { sessionId: 'session-a', range: () => ({ sessionId: 'session-a' }), + retain: () => false, sequenceForTurn: (turnId: string) => { const sequence = messages.findIndex((message) => message.turnId === turnId); return sequence < 0 ? null : sequence; @@ -270,7 +265,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { searchTarget: undefined, clearSearchTarget: () => {}, turnIndex: undefined, setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - setHistoryPending: () => {}, historyPageBytes: 512 * 1024, + setHistoryPending: () => {}, onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), }; let authority: TranscriptScrollAuthority | undefined; diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 959e8e1c5d..2e89efa1f1 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -309,8 +309,8 @@ test('WorkHub does not infer live running when the Session catalog is unavailabl }]))[0]?.state, 'recovering'); }); -// Keep the real preload's navigation defaults and filtering in this consumer -// regression; the IPC stub models the observer's authoritative reset reply. +// Keep the real preload in this consumer regression; the IPC stub models the +// observer's authoritative reset reply to a latest command. test('WorkHub tail navigation converges through the preload with a fragmented sparse tail', { timeout: 5_000 }, async (t) => { const owner = { hostId: 'owner-host', targetEpoch: 'owner-epoch', profileId: 'local', @@ -355,7 +355,7 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp } return { ...snapshot, readThroughMessageId: null }; } - if (channel === 'sessions:transcript:load-around') { + if (channel === 'sessions:transcript:load-latest') { const request = args[1] as DesktopTranscriptRangeRequest; requests.push(request); // Bound a regressed request loop so the test reports its cause. @@ -369,12 +369,8 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp deliver(batch); if (!batch.ready) { partialProjectionCounts.push(projections.length); - // A rejected ready/reset must not publish a partial valid snapshot - // or clear the load guard, even if a caller bypasses preload filtering. - deliverDirect?.({ - ...batch, navigationVersion: 0, fragments: [], ready: true, - deliverySequence: ++deliverySequence, - }); + // A batch from another replica generation must not publish a + // partial valid snapshot or clear the load guard. deliverDirect?.({ ...batch, generation: 'unrelated-generation', reset: false, fragments: [], ready: true, deliverySequence: ++deliverySequence, @@ -436,7 +432,6 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp await new Promise((resolve) => setImmediate(resolve)); assert.equal(requests.length, 1); assert.equal(requests[0]!.navigationVersion, 1); - assert.equal(requests[0]!.intent, 'followTail'); assert.equal(requests[0]!.anchorSequence, null); assert.deepEqual(partialProjectionCounts, [1, 1]); assert.deepEqual(projections, [[], ['latest-message']]); @@ -495,7 +490,7 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); }; return { ...snapshot, readThroughMessageId: null, - loadBefore: unavailable, loadAfter: unavailable, + loadBefore: unavailable, loadAfter: unavailable, loadLatest: unavailable, loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation?.navigationVersion), close: async () => { closedCount++; }, }; diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index 26c7efb2bc..bb9a8da213 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -26,6 +26,7 @@ import { import type { DesktopSequencedTranscriptMessage, DesktopTranscriptReplicaChange, + DesktopTranscriptReplicaPage, DesktopTranscriptReplicaSnapshot, } from './desktop-transcript-replica.js'; @@ -40,10 +41,8 @@ interface TranscriptBatchContent { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly overlay: readonly StoredMessage[]; - readonly evictedDurableSequences: readonly number[]; - readonly completedOverlayMessageIds: readonly string[]; - readonly hasOlder: boolean; - readonly hasNewer: boolean; + readonly hasOlder?: boolean; + readonly hasNewer?: boolean; readonly reset: boolean; } @@ -54,14 +53,26 @@ export function encodeDesktopTranscriptSnapshot( durableThrough: snapshot.durableThrough, durable: snapshot.durable, overlay: snapshot.overlay, - evictedDurableSequences: [], - completedOverlayMessageIds: [], hasOlder: snapshot.hasOlder, hasNewer: snapshot.hasNewer, reset: true, }); } +export function encodeDesktopTranscriptPage( + identity: TranscriptBatchIdentity, + page: DesktopTranscriptReplicaPage, +): Iterable { + return encodeDesktopTranscriptBatches(identity, { + durableThrough: page.durableThrough, + durable: page.durable, + overlay: [], + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + reset: false, + }); +} + export function encodeDesktopTranscriptChange( identity: TranscriptBatchIdentity, change: DesktopTranscriptReplicaChange, @@ -70,10 +81,6 @@ export function encodeDesktopTranscriptChange( durableThrough: change.durableThrough, durable: change.durableUpserts, overlay: [], - evictedDurableSequences: change.evictedDurableSequences, - completedOverlayMessageIds: change.completedOverlayMessageIds, - hasOlder: change.hasOlder, - hasNewer: change.hasNewer, reset: false, }); } @@ -84,15 +91,8 @@ function* encodeDesktopTranscriptBatches( ): Iterable { const fragments = encodeMessages(content); let fragment = fragments.next(); - let evictedIndex = 0; - let completedIndex = 0; let first = true; - while ( - !fragment.done || - evictedIndex < content.evictedDurableSequences.length || - completedIndex < content.completedOverlayMessageIds.length || - first - ) { + while (!fragment.done || first) { const batchFragments: DesktopTranscriptFragment[] = []; let rawBytes = 0; while (!fragment.done) { @@ -104,41 +104,19 @@ function* encodeDesktopTranscriptBatches( rawBytes += bytes; fragment = fragments.next(); } - const evictedDurableSequences = content.evictedDurableSequences.slice( - evictedIndex, - evictedIndex + 256, - ); - evictedIndex += evictedDurableSequences.length; - const completedOverlayMessageIds: string[] = []; - let identityBytes = 0; - while (completedIndex < content.completedOverlayMessageIds.length) { - const messageId = content.completedOverlayMessageIds[completedIndex]!; - const bytes = Buffer.byteLength(messageId, 'utf8'); - if ( - completedOverlayMessageIds.length >= 256 || - (completedOverlayMessageIds.length > 0 && - identityBytes + bytes > DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) - ) { - break; - } - completedOverlayMessageIds.push(messageId); - identityBytes += bytes; - completedIndex += 1; - } - const ready = - fragment.done === true && - evictedIndex === content.evictedDurableSequences.length && - completedIndex === content.completedOverlayMessageIds.length; yield { - ...identity, + ...(identity.navigationVersion === undefined + ? {} + : { navigationVersion: identity.navigationVersion }), + sessionId: identity.sessionId, + generation: identity.generation, + hostEpoch: identity.hostEpoch, durableThrough: content.durableThrough, fragments: batchFragments, - evictedDurableSequences, - completedOverlayMessageIds, - hasOlder: content.hasOlder, - hasNewer: content.hasNewer, + ...(content.hasOlder === undefined ? {} : { hasOlder: content.hasOlder }), + ...(content.hasNewer === undefined ? {} : { hasNewer: content.hasNewer }), reset: content.reset && first, - ready, + ready: fragment.done === true, }; first = false; } diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index d7c8568f3c..6b16318f9b 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -29,10 +29,9 @@ import { type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; import { - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, - type DesktopTranscriptNavigation, + DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -66,19 +65,30 @@ export interface DesktopTranscriptReplicaSnapshot { readonly hasNewer: boolean; } +/** A durable page read on behalf of one Renderer window; never installed here. */ +export interface DesktopTranscriptReplicaPage { + readonly durableThrough: number; + readonly durable: readonly DesktopSequencedTranscriptMessage[]; + readonly hasOlder?: boolean; + readonly hasNewer?: boolean; +} + +/** Tail-cache growth broadcast to every consumer; carries no window edges. */ export interface DesktopTranscriptReplicaChange { readonly durableThrough: number | null; readonly durableUpserts: readonly DesktopSequencedTranscriptMessage[]; - readonly evictedDurableSequences: readonly number[]; - readonly completedOverlayMessageIds: readonly string[]; - readonly hasOlder: boolean; - readonly hasNewer: boolean; } interface ResidentMessage extends DesktopSequencedTranscriptMessage { readonly encodedBytes: number; } +/** + * Main's view of one Session transcript: the durable tail the projector needs, + * the overlay of not-yet-durable messages, and a pass-through pager for the + * Renderer's own window. The Renderer decides what it holds; this class only + * keeps the tail current and answers page reads. + */ export class DesktopTranscriptReplica { readonly sessionId: string; readonly generation: string; @@ -98,20 +108,13 @@ export class DesktopTranscriptReplica { #residentBytes = 0; #overlayBytes = 0; #durableThrough: number | null; - #overlaySettledThrough: number | null; #targetThrough: number | null; #hasOlder: boolean; - #hasNewer = false; #resident = true; #residentExternallyAccounted = true; #closed = false; #catchUpTask: Promise | undefined; #operationTail = Promise.resolve(); - #navigationToken = 0; - #intent: DesktopTranscriptNavigation['intent'] = 'followTail'; - #readingAnchorSequence: number | undefined; - #readingAnchorTurnId: string | undefined; - #adjacentReadingSequence: number | undefined; private constructor( handle: DesktopRuntimeHostSession, @@ -124,14 +127,13 @@ export class DesktopTranscriptReplica { this.#maxResidentBytes = options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; this.#maxResidentTurns = - options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS; + options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS; this.#maxOverlayBytes = options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; this.#maxMessageBytes = options.maxMessageBytes ?? SESSION_TRANSCRIPT_RANGE_MAX_BYTES; this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); this.#onChange = options.onChange ?? (() => undefined); this.#durableThrough = handle.transcriptBootstrap.throughSequence; - this.#overlaySettledThrough = this.#durableThrough; this.#targetThrough = this.#durableThrough; this.#hasOlder = handle.transcriptBootstrap.durable.nextCursor !== null; } @@ -153,7 +155,6 @@ export class DesktopTranscriptReplica { }); replica.#evictToBudget( undefined, - 'oldest', handle.transcriptBootstrap.durable.protectedTurnSequence ?? replica.#durableThrough ?? undefined, @@ -202,7 +203,7 @@ export class DesktopTranscriptReplica { durable: this.#orderedDurable(false), overlay: [...this.#overlay.values()], hasOlder: this.#hasOlder, - hasNewer: this.#hasNewer, + hasNewer: false, }; } @@ -233,271 +234,116 @@ export class DesktopTranscriptReplica { return latest?.message.id ?? null; } - setNavigation(intent: DesktopTranscriptNavigation['intent']): number { - this.#assertOpen(); - this.#intent = intent; - if (intent === 'followTail') { - this.#readingAnchorSequence = undefined; - this.#readingAnchorTurnId = undefined; - this.#adjacentReadingSequence = undefined; - } - return ++this.#navigationToken; - } - - readAt( - sequence: number | null, - token = this.setNavigation('history'), - readingTurnId?: string, - ): Promise { - return this.#enqueue(async () => { - if (!this.#isNavigationCurrent(token)) return; - let anchor = readingTurnId === undefined - ? sequence - : this.#sequenceForTurn(readingTurnId) ?? sequence; - const through = this.#targetThrough ?? this.#durableThrough; - if (anchor === null && readingTurnId !== undefined && through !== null && - !(through <= (this.#overlaySettledThrough ?? -1) && - [...this.#overlay.values()].some((message) => message.turnId === readingTurnId))) { - // After reconnect, an overlay-only bookmark may already be durable and - // outside the bootstrap tail. Locate it through the existing bounded - // pager; retaining only its sequence keeps the scan's memory bounded. - anchor = await this.#findTurnSequence(readingTurnId, through, token); - } - if (!this.#isNavigationCurrent(token)) return; - if (anchor === null) { - // Active RuntimeEvent invocations have no durable sequence. Put the - // durable range at its current tail, then protect the requested Turn - // when catch-up first projects it. Never borrow another Turn's anchor. - if (through !== null && this.#hasNewer) { - await this.#replaceWithRange(through, through, this.#maxResidentBytes, token); - } - if (!this.#isNavigationCurrent(token)) return; - this.#readingAnchorTurnId = readingTurnId; - this.#readingAnchorSequence = undefined; - this.#adjacentReadingSequence = undefined; - this.#publish([], [], []); - return; - } - if (!this.#durable.has(anchor)) { - if (through !== null && anchor <= through) { - await this.#replaceWithRange(through, anchor, this.#maxResidentBytes, token); - } - return; - } - this.#readingAnchorSequence = anchor; - this.#readingAnchorTurnId = readingTurnId ?? this.#durable.get(anchor)?.message.turnId; - this.#adjacentReadingSequence = undefined; - const evicted = this.#evictToBudget(undefined, 'newest', anchor); - this.#publish([], [], evicted); - }); - } - - #sequenceForTurn(turnId: string): number | undefined { - for (const entry of this.#orderedDurable(false)) { - if (entry.message.turnId === turnId) return entry.sequence; - } - return undefined; - } - - #resolveReadingAnchor(): number | undefined { - if (this.#readingAnchorTurnId !== undefined) { - this.#readingAnchorSequence = this.#sequenceForTurn(this.#readingAnchorTurnId) - ?? this.#readingAnchorSequence; - } - return this.#readingAnchorSequence; - } - - #awaitingReadingTurn(): boolean { - return this.#readingAnchorTurnId !== undefined && this.#resolveReadingAnchor() === undefined; - } - - async #findTurnSequence(turnId: string, throughSequence: number, token: number): Promise { - let cursor: string | null = null; - do { - if (!this.#isNavigationCurrent(token)) return null; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', direction: 'older', throughSequence, - cursor, anchorSequence: null, maxBytes: this.#maxResidentBytes, - }); - let sequence: number | undefined; - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - sequence = decoded.messages.find((entry) => entry.message.turnId === turnId)?.identity; - cursor = decoded.nextCursor; - }); - if (!this.#isNavigationCurrent(token)) return null; - if (sequence !== undefined) return sequence; - } while (cursor !== null); - return null; - } - - async followLatest(maxBytes: number, token = this.setNavigation('followTail')): Promise { - return this.#enqueue(async () => { - if (!this.#isNavigationCurrent(token)) return; - const through = this.#targetThrough ?? this.#durableThrough; - if (through !== null) await this.#replaceWithRange(through, through, maxBytes, token); - }); - } - - async loadBefore( + loadBefore( anchorSequence: number | null, maxBytes: number, - token = this.setNavigation('history'), - ): Promise { - return this.#enqueue(() => this.#loadAdjacent('older', anchorSequence, maxBytes, token)); + isCurrent: () => boolean = () => true, + ): Promise { + return this.#enqueue(() => this.#loadPage('older', anchorSequence, maxBytes, isCurrent)); } - async loadAfter( + loadAfter( anchorSequence: number | null, maxBytes: number, - token = this.setNavigation('history'), - ): Promise { - return this.#enqueue(() => this.#loadAdjacent('newer', anchorSequence, maxBytes, token)); + isCurrent: () => boolean = () => true, + ): Promise { + return this.#enqueue(() => this.#loadPage('newer', anchorSequence, maxBytes, isCurrent)); } - async #loadAdjacent( + async #loadPage( direction: 'older' | 'newer', anchorSequence: number | null, maxBytes: number, - token: number, - ): Promise { - if (!this.#isNavigationCurrent(token)) return; + isCurrent: () => boolean, + ): Promise { + if (!this.#isLive() || !isCurrent()) return undefined; const throughSequence = this.#durableThrough; - if (throughSequence === null) return; - const anchor = anchorSequence ?? (direction === 'older' - ? this.#oldestSequence() - : this.#orderedDurable(false).at(-1)?.sequence ?? null); + if (throughSequence === null) return undefined; const page = await this.#handle.loadTranscriptPage({ source: 'durable', direction, throughSequence, cursor: null, - anchorSequence: anchor, + anchorSequence, maxBytes, }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - // Same post-await `#resident` invariant as `#replaceWithRange` and the - // paged catch-up: a concurrent `discard()` may have reclaimed this - // replica while the adjacent page was in flight. Installing the page here - // would repopulate durable state and undo the eviction. - if (!this.#resident) return; + return this.#withDecodedPage(page, (decoded) => { + if (!this.#isLive() || !isCurrent()) return undefined; this.#acceptRange(decoded.messages); if ( - anchor !== null && + anchorSequence !== null && decoded.messages.length > 0 && !(direction === 'older' - ? this.#matchesCoverageStep(anchor, decoded.messages.at(-1)!.identity + 1) - : this.#matchesCoverageStep(decoded.messages[0]!.identity, anchor + 1)) + ? this.#matchesCoverageStep(anchorSequence, decoded.messages.at(-1)!.identity + 1) + : this.#matchesCoverageStep(decoded.messages[0]!.identity, anchorSequence + 1)) ) { throw correlationError(`Desktop transcript ${direction} page did not meet its anchor`); } - const completedOverlayMessageIds = this.#installDurable(decoded.messages); - if (direction === 'older') this.#hasOlder = decoded.nextCursor !== null; - else this.#hasNewer = decoded.nextCursor !== null; - const anchorTurnId = anchor === null ? undefined : this.#durable.get(anchor)?.message.turnId; - const towardEdge = direction === 'older' ? [...decoded.messages].reverse() : decoded.messages; - const adjacent = towardEdge.find(({ message }) => - messageTurnId(message) !== undefined && messageTurnId(message) !== anchorTurnId, - ) ?? towardEdge.at(-1); - this.#readingAnchorSequence = anchor ?? undefined; - this.#readingAnchorTurnId = anchorTurnId; - this.#adjacentReadingSequence = adjacent?.identity; - const evictedDurableSequences = this.#evictToBudget( - undefined, - direction === 'older' ? 'newest' : 'oldest', - anchor ?? undefined, - adjacent?.identity, - ); - this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + this.#completeOverlay(decoded.messages); + return { + durableThrough: throughSequence, + durable: decoded.messages.map((entry) => ({ + sequence: entry.identity, + message: entry.message, + })), + ...(direction === 'older' + ? { hasOlder: decoded.nextCursor !== null } + : { hasNewer: decoded.nextCursor !== null }), + }; }); } - async loadAround( - sequence: number, - maxBytes: number, - token = this.setNavigation('history'), - ): Promise { - return this.#enqueue(() => this.#loadAround(sequence, maxBytes, token)); - } - - async #loadAround(sequence: number, maxBytes: number, token: number): Promise { - if (!this.#isNavigationCurrent(token)) return; - const throughSequence = this.#durableThrough; - if (throughSequence === null || sequence > throughSequence) return; - await this.#replaceWithRange(throughSequence, sequence, maxBytes, token); - } - - async #replaceWithRange( - throughSequence: number, + loadAround( sequence: number, maxBytes: number, - token: number, - ): Promise { - const loadTail = sequence === throughSequence; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: loadTail ? 'older' : 'newer', - throughSequence, - cursor: null, - anchorSequence: loadTail ? sequence + 1 : sequence === 0 ? null : sequence - 1, - maxBytes, - }); - if (!this.#isNavigationCurrent(token)) return; - // A durable sequence is an event ordinal times its stride, so the oldest row - // of a Session is at no fixed number and `sequence > 0` cannot answer this. - // Ask for one row older than the anchor instead; a jump is user-initiated, - // so the extra bounded read is paid once per jump. - const older = loadTail - ? null - : await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: 'older', - throughSequence, - cursor: null, - anchorSequence: sequence, - maxBytes: 1, - }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - // `#resident` can flip to false across the `await` above (a concurrent - // `discard()` reclaims memory for a non-visible session while the page is - // in flight). Re-anchoring here would repopulate durable state and undo - // the eviction, resurrecting a deliberately discarded replica past its - // memory budget. The paged catch-up guards its own post-await callback - // the same way; mirror it before mutating or publishing. - if (!this.#resident) return; - this.#acceptRange(decoded.messages); - if ( - decoded.messages.length > 0 && - (loadTail - ? !this.#matchesCoverageStep(sequence, decoded.messages.at(-1)!.identity) - : decoded.messages[0]!.identity !== sequence) - ) { - throw correlationError('Desktop transcript range did not meet its anchor'); - } - const evictedDurableSequences = [...this.#durable.keys()]; - this.#clearDurable(); - const completedOverlayMessageIds = this.#installDurable(decoded.messages); - this.#durableThrough = throughSequence; - this.#readingAnchorSequence = this.#intent === 'history' ? sequence : undefined; - this.#readingAnchorTurnId = this.#intent === 'history' - ? this.#durable.get(sequence)?.message.turnId : undefined; - this.#adjacentReadingSequence = undefined; - this.#hasOlder = loadTail ? decoded.nextCursor !== null : older!.fragments.length > 0; - this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; - evictedDurableSequences.push( - ...this.#evictToBudget( - undefined, - loadTail ? 'oldest' : 'newest', - loadTail ? (page.protectedTurnSequence ?? sequence) : sequence, - ), - ); - this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + isCurrent: () => boolean = () => true, + ): Promise { + return this.#enqueue(async () => { + if (!this.#isLive() || !isCurrent()) return undefined; + const throughSequence = this.#durableThrough; + if (throughSequence === null || sequence > throughSequence) return undefined; + const page = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'newer', + throughSequence, + cursor: null, + anchorSequence: sequence === 0 ? null : sequence - 1, + maxBytes, + }); + if (!this.#isLive() || !isCurrent()) return undefined; + // A durable sequence is an event ordinal times its stride, so the oldest + // row of a Session is at no fixed number and `sequence > 0` cannot answer + // whether anything precedes the anchor. Ask for one row older instead. + const older = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'older', + throughSequence, + cursor: null, + anchorSequence: sequence, + maxBytes: 1, + }); + return this.#withDecodedPage(page, (decoded) => { + if (!this.#isLive() || !isCurrent()) return undefined; + this.#acceptRange(decoded.messages); + if (decoded.messages.length > 0 && decoded.messages[0]!.identity !== sequence) { + throw correlationError('Desktop transcript range did not meet its anchor'); + } + this.#completeOverlay(decoded.messages); + return { + sessionId: this.sessionId, + generation: this.generation, + hostEpoch: this.hostEpoch, + durableThrough: throughSequence, + durable: decoded.messages.map((entry) => ({ + sequence: entry.identity, + message: entry.message, + })), + overlay: [...this.#overlay.values()], + hasOlder: older.fragments.length > 0, + hasNewer: decoded.nextCursor !== null, + }; + }); }); - if (this.#isNavigationCurrent(token) && this.#needsOverlaySettlement(throughSequence)) { - await this.#settleOverlayThrough(throughSequence, token); - } } advance(throughSequence: number): Promise { @@ -514,8 +360,7 @@ export class DesktopTranscriptReplica { if ( !this.#closed && this.#targetThrough !== null && - (this.#durableThrough === null || this.#targetThrough > this.#durableThrough || - this.#needsOverlaySettlement(this.#targetThrough)) + (this.#durableThrough === null || this.#targetThrough > this.#durableThrough) ) { void this.advance(this.#targetThrough).catch(() => undefined); } @@ -523,13 +368,10 @@ export class DesktopTranscriptReplica { return this.#catchUpTask; } - trimDurable(targetResidentBytes: number): DesktopTranscriptReplicaChange | undefined { + trimDurable(targetResidentBytes: number): void { this.#assertOpen(); - if (!this.#resident) return undefined; - const evictedDurableSequences = this.#evictToBudget(targetResidentBytes); - return evictedDurableSequences.length === 0 - ? undefined - : this.#change([], [], evictedDurableSequences); + if (!this.#resident) return; + this.#evictToBudget(targetResidentBytes); } discard(): void { @@ -557,34 +399,15 @@ export class DesktopTranscriptReplica { } async #catchUp(): Promise { - while (!this.#closed && this.#resident) { + while (this.#isLive()) { const target = this.#targetThrough; if (target === null) return; - if ( - this.#durableThrough !== null && target <= this.#durableThrough && - !this.#needsOverlaySettlement(target) - ) return; - const token = this.#navigationToken; - if ( - (this.#durableThrough !== null && target <= this.#durableThrough) || - (this.#intent === 'history' && this.#hasNewer && !this.#awaitingReadingTurn()) - ) { - await this.#settleOverlayThrough(target, token); - if (!this.#isNavigationCurrent(token)) return; - if (this.#durableThrough !== null && target <= this.#durableThrough) return; - this.#durableThrough = target; - this.#publish([], [], []); - return; - } - if (this.#hasNewer && !this.#awaitingReadingTurn()) { - await this.#replaceWithRange(target, target, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, token); - return; - } - let cursor: string | null = null; const anchorSequence = this.#durableThrough; + if (anchorSequence !== null && target <= anchorSequence) return; + let cursor: string | null = null; let nextSequence = (anchorSequence ?? -1) + 1; do { - if (!this.#isNavigationCurrent(token)) return; + if (!this.#isLive()) return; const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ source: 'durable', direction: 'newer', @@ -594,7 +417,10 @@ export class DesktopTranscriptReplica { maxBytes: 512 * 1024, }); await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; + // A concurrent `discard()` (LRU reclaim for another observed session) + // can flip `#resident` across the `await` above; installing the page + // would resurrect the reclaimed replica. + if (!this.#isLive()) return; if (decoded.messages.length === 0 && decoded.nextCursor !== null) { throw correlationError('Desktop transcript catch-up returned an empty continuation'); } @@ -608,46 +434,18 @@ export class DesktopTranscriptReplica { if (decoded.messages.length > 0) { nextSequence = decoded.messages.at(-1)!.identity + 1; } - const completedOverlayMessageIds = this.#installDurable(decoded.messages); - this.#acknowledgeOverlayCoverage(anchorSequence, decoded.messages.at(-1)?.identity); - // Reading the start of the still-growing latest Turn must continue - // receiving its durable text. Preserve the reader's anchor (and an - // older page awaiting the reader), rather than protecting a new Turn - // that could evict the range they are reading. - const readingHistory = this.#intent === 'history'; - const readingSequence = readingHistory ? this.#resolveReadingAnchor() : undefined; - const awaitingReadingTurn = readingHistory && this.#awaitingReadingTurn(); - const evictedDurableSequences = this.#evictToBudget( + this.#installDurable(decoded.messages); + this.#evictToBudget( undefined, - readingHistory && !awaitingReadingTurn ? 'newest' : 'oldest', - readingHistory && !awaitingReadingTurn - ? readingSequence ?? this.#oldestSequence() ?? undefined - : page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, - readingHistory ? this.#adjacentReadingSequence : undefined, + page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, ); - this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + this.#publish(decoded.messages); cursor = decoded.nextCursor; }); - if (!this.#isNavigationCurrent(token)) return; - if (this.#intent === 'history' && this.#hasNewer && !this.#awaitingReadingTurn()) { - await this.#settleOverlayThrough(target, token); - if (!this.#isNavigationCurrent(token)) return; - this.#durableThrough = target; - this.#publish([], [], []); - return; - } } while (cursor !== null); - // A concurrent `discard()` (LRU reclaim for another observed session) can - // flip `#resident` to false across any page `await` above. The per-page - // callback already returns early in that case, so `nextSequence` is - // left short of the watermark. Without this guard the check below would - // turn a benign memory reclaim into a fatal `correlation_changed` that - // drives the session terminal. A discarded replica has no watermark to - // meet, so return cleanly and let a later resume re-catch-up. - if (!this.#isNavigationCurrent(token)) return; - this.#acknowledgeOverlayCoverage(anchorSequence, target); + if (!this.#isLive()) return; this.#durableThrough = target; - this.#publish([], [], []); + this.#publish([]); } } @@ -660,73 +458,12 @@ export class DesktopTranscriptReplica { } } - #needsOverlaySettlement(throughSequence: number): boolean { - return this.#overlay.size > 0 && - (this.#overlaySettledThrough === null || throughSequence > this.#overlaySettledThrough); - } - - #acknowledgeOverlayCoverage(anchorSequence: number | null, throughSequence: number | undefined): void { - if ( - throughSequence !== undefined && - (anchorSequence ?? -1) <= (this.#overlaySettledThrough ?? -1) - ) { - this.#overlaySettledThrough = Math.max(this.#overlaySettledThrough ?? -1, throughSequence); - } - } - - async #settleOverlayThrough(throughSequence: number, token: number): Promise { - // A navigation can skip durable pages while the bootstrap overlay still - // contains an unfinished message from one of those pages. Its settlement - // watermark must therefore be independent of the visible range watermark. - // Only matching durable identities retire overlay records; unrelated new - // messages are decoded one page at a time without entering the range. - if (!this.#needsOverlaySettlement(throughSequence)) return; - const anchorSequence = this.#overlaySettledThrough; - let nextSequence = (anchorSequence ?? -1) + 1; - let cursor: string | null = null; - do { - if (!this.#isNavigationCurrent(token)) return; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: 'newer', - throughSequence, - cursor, - anchorSequence: cursor === null ? anchorSequence : null, - maxBytes: 512 * 1024, - }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - if (decoded.messages.length === 0 && decoded.nextCursor !== null) { - throw correlationError('Desktop transcript overlay settlement returned an empty continuation'); - } - this.#acceptRange(decoded.messages); - if (decoded.messages.length > 0) { - if (!this.#matchesCoverageStep(decoded.messages[0]!.identity, nextSequence)) { - throw correlationError('Desktop transcript overlay settlement has a sequence gap'); - } - const lastSequence = decoded.messages.at(-1)!.identity; - nextSequence = lastSequence + 1; - this.#overlaySettledThrough = lastSequence; - } - const completedOverlayMessageIds = this.#completeOverlay(decoded.messages); - if (completedOverlayMessageIds.length > 0) { - this.#publish([], completedOverlayMessageIds, []); - } - cursor = decoded.nextCursor; - }); - if (!this.#isNavigationCurrent(token) || this.#overlay.size === 0) return; - } while (cursor !== null); - // RuntimeEvent projection can leave gaps and a watermark beyond its last - // visible row. Exhausting the correlated cursor establishes coverage. - this.#overlaySettledThrough = throughSequence; - } - #installDurable( messages: readonly { readonly identity: number; readonly message: StoredMessage; }[], - ): string[] { + ): void { for (const item of messages) { const previous = this.#durable.get(item.identity); if (previous && previous.message.id !== item.message.id) { @@ -742,20 +479,21 @@ export class DesktopTranscriptReplica { }); this.#adjustResidentBytes(encodedBytes); } - return this.#completeOverlay(messages); + this.#completeOverlay(messages); } - #completeOverlay(messages: readonly { readonly message: StoredMessage }[]): string[] { - const completedOverlayMessageIds: string[] = []; + /** + * The durable row settles the overlay it replaces, so the tail cache stops + * carrying both. Each window retires its own overlay when it installs the + * row; a window that never installs it keeps showing what it has. + */ + #completeOverlay(messages: readonly { readonly message: StoredMessage }[]): void { for (const { message } of messages) { const overlay = this.#overlay.get(message.id); - if (overlay) { - this.#overlay.delete(message.id); - this.#adjustOverlayBytes(-encodedMessageBytes(overlay)); - completedOverlayMessageIds.push(message.id); - } + if (!overlay) continue; + this.#overlay.delete(message.id); + this.#adjustOverlayBytes(-encodedMessageBytes(overlay)); } - return completedOverlayMessageIds; } #acceptRange( @@ -783,110 +521,53 @@ export class DesktopTranscriptReplica { readonly identity: number; readonly message: StoredMessage; }[], - completedOverlayMessageIds: readonly string[], - evictedDurableSequences: readonly number[], ): void { - this.#onChange(this, this.#change(messages, completedOverlayMessageIds, evictedDurableSequences)); - } - - #change( - messages: readonly { - readonly identity: number; - readonly message: StoredMessage; - }[], - completedOverlayMessageIds: readonly string[], - evictedDurableSequences: readonly number[], - ): DesktopTranscriptReplicaChange { - return { + this.#onChange(this, { durableThrough: this.#durableThrough, - durableUpserts: messages.flatMap((entry) => { - const resident = this.#durable.get(entry.identity); - return resident?.message.id === entry.message.id - ? [{ sequence: entry.identity, message: resident.message }] - : []; - }), - evictedDurableSequences: [...new Set(evictedDurableSequences)].filter( - (sequence) => !this.#durable.has(sequence), - ), - completedOverlayMessageIds, - hasOlder: this.#hasOlder, - hasNewer: this.#hasNewer, - }; + // Every row this catch-up read, whether or not the tail cache kept it: + // the budget that evicts it here is Main's, not any window's. + durableUpserts: messages.map((entry) => ({ + sequence: entry.identity, + message: entry.message, + })), + }); } + /** + * Evicts whole Turns from the oldest edge until the tail fits. The protected + * Turn and everything newer stay even when they alone exceed the budget: the + * projector needs the newest Turn complete. Global pressure calls with no + * protection and may empty the tail. + */ #evictToBudget( budget: number | undefined = undefined, - edge: 'oldest' | 'newest' = 'oldest', protectedSequence?: number, - protectedThroughSequence = protectedSequence, - ): number[] { + ): void { const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; - const evicted: number[] = []; - const sequences = [...this.#durable.keys()].sort((left, right) => left - right); - const turnGroups = new Map(); - for (const sequence of sequences) { - const entry = this.#durable.get(sequence); - if (!entry) continue; - const turnKey = residentTurnKey(entry); - const group = turnGroups.get(turnKey); + const turns = new Map(); + for (const sequence of [...this.#durable.keys()].sort((left, right) => left - right)) { + const key = residentTurnKey(this.#durable.get(sequence)!); + const group = turns.get(key); if (group) group.push(sequence); - else turnGroups.set(turnKey, [sequence]); + else turns.set(key, [sequence]); } - const orderedTurns = [...turnGroups.entries()]; - let oldestIndex = 0; - let newestIndex = orderedTurns.length - 1; - let residentTurns = orderedTurns.length; - const protectedIndices = [protectedSequence, protectedThroughSequence].flatMap((sequence) => { - const entry = sequence === undefined ? undefined : this.#durable.get(sequence); - return entry === undefined ? [] : [orderedTurns.findIndex(([key]) => key === residentTurnKey(entry))]; - }); - const protectedStart = Math.min(...protectedIndices); - const protectedEnd = Math.max(...protectedIndices); - // A single oversized Turn already outranks the per-range soft budget. - // Adjacent navigation needs the same exception for the minimal span from - // the reader to the next Turn; otherwise that Turn is evicted on arrival - // and every subsequent scroll reloads it without making progress. Global - // pressure calls trimDurable without protection and still reclaims it. - const take = ( - candidateEdge: 'oldest' | 'newest', - ): readonly [string, number[]] | undefined => { - const index = candidateEdge === 'oldest' ? oldestIndex : newestIndex; - if (oldestIndex > newestIndex) return undefined; - const turn = orderedTurns[index]; - if (!turn || (index >= protectedStart && index <= protectedEnd)) return undefined; - if (candidateEdge === 'oldest') oldestIndex += 1; - else newestIndex -= 1; - return turn; - }; - while ( - this.#residentBytes > residentBudget - || residentTurns > this.#maxResidentTurns - ) { - let evictionEdge = protectedIndices.length === 0 - ? edge - : protectedStart - oldestIndex > newestIndex - protectedEnd - ? 'oldest' - : protectedStart - oldestIndex < newestIndex - protectedEnd - ? 'newest' - : edge; - let turn = take(evictionEdge); - if (turn === undefined) { - evictionEdge = evictionEdge === 'oldest' ? 'newest' : 'oldest'; - turn = take(evictionEdge); - } - if (turn === undefined) break; - for (const sequence of turn[1]) { + const protectedEntry = protectedSequence === undefined + ? undefined + : this.#durable.get(protectedSequence); + const protectedKey = protectedEntry === undefined ? undefined : residentTurnKey(protectedEntry); + let residentTurns = turns.size; + for (const [key, sequences] of turns) { + if (this.#residentBytes <= residentBudget && residentTurns <= this.#maxResidentTurns) return; + if (key === protectedKey) return; + for (const sequence of sequences) { const entry = this.#durable.get(sequence); if (!entry) continue; this.#durable.delete(sequence); this.#adjustResidentBytes(-entry.encodedBytes); - evicted.push(sequence); } residentTurns -= 1; - if (evictionEdge === 'oldest') this.#hasOlder = true; - else this.#hasNewer = true; + this.#hasOlder = true; } - return evicted; } #orderedDurable(cloneMessages = true): DesktopSequencedTranscriptMessage[] { @@ -898,14 +579,6 @@ export class DesktopTranscriptReplica { })); } - #oldestSequence(): number | null { - let oldest: number | null = null; - for (const sequence of this.#durable.keys()) { - if (oldest === null || sequence < oldest) oldest = sequence; - } - return oldest; - } - #clearDurable(): void { for (const entry of this.#durable.values()) this.#adjustResidentBytes(-entry.encodedBytes); this.#durable.clear(); @@ -960,8 +633,8 @@ export class DesktopTranscriptReplica { } } - #isNavigationCurrent(token: number): boolean { - return !this.#closed && this.#resident && token === this.#navigationToken; + #isLive(): boolean { + return !this.#closed && this.#resident; } #assertOpen(): void { @@ -974,9 +647,9 @@ export class DesktopTranscriptReplica { } } - #enqueue(operation: () => Promise): Promise { + #enqueue(operation: () => Promise): Promise { const task = this.#operationTail.then(operation); - this.#operationTail = task.catch(() => undefined); + this.#operationTail = task.then(() => undefined, () => undefined); return task; } } diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index d6b6234f8b..2eea4a743c 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -177,6 +177,7 @@ export interface RuntimeHostSessionObservationIpcDeps { | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'observe' | 'openTranscript' >; @@ -228,6 +229,12 @@ export function registerRuntimeHostSessionObservationIpc( event.sender.id, ); }); + ipcMain.handle('sessions:transcript:load-latest', async (event, input: unknown) => { + await deps.observations.loadTranscriptLatest( + normalizeTranscriptRangeRequest(input), + event.sender.id, + ); + }); } /** @@ -826,12 +833,7 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange throw new Error('Invalid Desktop transcript range byte limit'); } if ( - (value.navigationVersion !== undefined && - (!Number.isSafeInteger(value.navigationVersion) || (value.navigationVersion as number) < 0)) || - (value.intent !== undefined && value.intent !== 'history' && value.intent !== 'followTail') || - (value.preserveRange !== undefined && typeof value.preserveRange !== 'boolean') || - (value.readingTurnId !== undefined && - (typeof value.readingTurnId !== 'string' || value.readingTurnId.length === 0)) + !Number.isSafeInteger(value.navigationVersion) || (value.navigationVersion as number) < 0 ) { throw new Error('Invalid Desktop transcript navigation'); } @@ -841,10 +843,7 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), anchorSequence: anchorSequence as number | null, maxBytes: maxBytes as number, - navigationVersion: value.navigationVersion as number | undefined, - intent: value.intent as DesktopTranscriptRangeRequest['intent'], - preserveRange: value.preserveRange as boolean | undefined, - readingTurnId: value.readingTurnId as string | undefined, + navigationVersion: value.navigationVersion as number, }; } diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index 8b0379b5b8..3384a671e1 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -41,6 +41,7 @@ type SessionObservationSource = Pick & | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'openTranscript' > >; @@ -52,6 +53,7 @@ type TranscriptSource = Required< | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'openTranscript' > >; @@ -74,6 +76,7 @@ function requireTranscriptSource( !source.loadTranscriptBefore || !source.loadTranscriptAfter || !source.loadTranscriptAround || + !source.loadTranscriptLatest || !source.closeTranscript ) { throw new Error('Runtime Host transcript source is unavailable'); @@ -95,19 +98,6 @@ function isMissingRuntimeHostSessionError(error: unknown): boolean { return error.code === "not_found"; } -function recoveredTranscriptRequest( - request: DesktopTranscriptRangeRequest, - hostEpoch: string, -): DesktopTranscriptRangeRequest { - return { - ...request, preserveRange: false, - ...(request.hostEpoch === hostEpoch ? {} : { - hostEpoch, anchorSequence: null, - ...(request.readingTurnId === undefined ? { intent: 'followTail' as const } : {}), - }), - }; -} - interface SessionObservationRegistration { readonly sessionId: string; readonly messageAdmissions: boolean; @@ -124,14 +114,7 @@ interface TranscriptRegistration { readonly ready: TranscriptReadiness; restore: ObservationReadiness | undefined; restoreOpened: boolean; - hostEpoch?: string; - recoveredIdentity?: { - readonly source: SessionObservationSource; - readonly previousHostEpoch: string; - readonly hostEpoch: string; - }; lifecycle: 'pending' | 'active'; - navigation?: { readonly request: DesktopTranscriptRangeRequest }; } interface TranscriptReadiness { @@ -389,7 +372,6 @@ export class RuntimeHostSessionObservationRegistry { ); if (this.#source === source && this.#transcripts.get(consumerId) === registration) { registration.lifecycle = 'active'; - registration.hostEpoch = result.hostEpoch; registration.ready.resolve(result); } else { await transcriptSource.closeTranscript(consumerId); @@ -408,10 +390,8 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request, (source, accepted) => - accepted === request - ? source.loadTranscriptBefore(accepted, targetId) - : source.loadTranscriptAround(accepted, targetId), + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptBefore(request, targetId), ); } @@ -419,8 +399,8 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request, (source, accepted) => - source.loadTranscriptAround(accepted, targetId), + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptAround(request, targetId), ); } @@ -428,10 +408,17 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request, (source, accepted) => - accepted === request - ? source.loadTranscriptAfter(accepted, targetId) - : source.loadTranscriptAround(accepted, targetId), + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptAfter(request, targetId), + ); + } + + async loadTranscriptLatest( + request: DesktopTranscriptRangeRequest, + targetId?: number, + ): Promise { + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptLatest(request, targetId), ); } @@ -520,46 +507,25 @@ export class RuntimeHostSessionObservationRegistry { async #runTranscriptOperation( request: DesktopTranscriptRangeRequest, - operation: ( - source: SessionObservationSource & TranscriptSource, - accepted: DesktopTranscriptRangeRequest, - ) => Promise, + operation: (source: SessionObservationSource & TranscriptSource) => Promise, ): Promise { const consumerId = request.consumerId; const registration = this.#transcripts.get(consumerId); if (!registration) { throw new Error('Desktop transcript consumer does not exist'); } - if ((request.navigationVersion ?? 0) < (registration.navigation?.request.navigationVersion ?? 0)) return; - const navigation = { request }; - registration.navigation = navigation; const source = requireTranscriptSource(this.#source); try { const restore = registration.restore; if (restore && !registration.restoreOpened) await restore.promise; - if ( - this.#source !== source || - this.#transcripts.get(consumerId) !== registration || - registration.navigation !== navigation - ) { + if (this.#source !== source || this.#transcripts.get(consumerId) !== registration) { return; } - const recovered = registration.recoveredIdentity; - if (recovered?.source === source && request.hostEpoch === recovered.previousHostEpoch) { - // A successful open proves this consumer moved to this source. Its - // preload may still await the replay snapshot before learning the new - // epoch; admit a newer intent without accepting arbitrary stale epochs. - navigation.request = recoveredTranscriptRequest(request, recovered.hostEpoch); - } - await operation(source, navigation.request); + await operation(source); } catch (error) { // Once either owner changes, this rejection belongs to stale work and // must not escape as a failure of the current renderer intent. - if ( - this.#source !== source || - this.#transcripts.get(consumerId) !== registration || - registration.navigation !== navigation - ) { + if (this.#source !== source || this.#transcripts.get(consumerId) !== registration) { return; } throw error; @@ -595,23 +561,7 @@ export class RuntimeHostSessionObservationRegistry { this.#transcripts.get(consumerId) === registration && registration.restore === restore ) { - const previousHostEpoch = registration.hostEpoch; - if (previousHostEpoch && previousHostEpoch !== result.hostEpoch) { - registration.recoveredIdentity = { source, previousHostEpoch, hostEpoch: result.hostEpoch }; - } - registration.hostEpoch = result.hostEpoch; registration.restoreOpened = true; - // Reconnection owns no new navigation intent. Reapply only the latest - // command admitted while the old source was alive or recovery waited. - const navigation = registration.navigation; - if (navigation) { - const request = recoveredTranscriptRequest(navigation.request, result.hostEpoch); - await transcriptSource.loadTranscriptAround(request, registration.target.id); - if (this.#source !== source || registration.restore !== restore) { - restore.resolve(); - return; - } - } registration.lifecycle = 'active'; registration.ready.resolve(result); registration.restore = undefined; diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 2993097826..32258ae67a 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -58,6 +58,7 @@ import { } from './desktop-transcript-replica.js'; import { encodeDesktopTranscriptChange, + encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, } from './desktop-transcript-ipc.js'; @@ -130,12 +131,12 @@ interface TranscriptConsumer { readonly target: RuntimeHostTranscriptTarget; generation: string; navigationVersion: number; - navigationPending: boolean; - navigationRequest?: DesktopTranscriptRangeRequest; deliverySequence: number; deliveryBytes: number; deliveryTask?: Promise; resetRequested: boolean; + /** Page answers queued behind the delivery loop so they never interleave with a change. */ + readonly pendingPages: PendingTranscriptPage[]; pendingChange?: PendingTranscriptChange; readonly pendingDeliveries: Map; - readonly evictedDurableSequences: Set; - readonly completedOverlayMessageIds: Set; - hasOlder: boolean; - hasNewer: boolean; encodedBytes: number; } +interface PendingTranscriptPage { + readonly navigationVersion: number; + readonly generation: string; + readonly batches: Iterable; + readonly encodedBytes: number; +} + interface PendingTranscriptUpsert { readonly entry: DesktopSequencedTranscriptMessage; readonly encodedBytes: number; @@ -297,10 +301,10 @@ export class RuntimeHostSessionObserver { target, generation: replica.generation, navigationVersion: 0, - navigationPending: false, deliverySequence: 0, deliveryBytes: 0, resetRequested: false, + pendingPages: [], pendingDeliveries: new Map(), }; state.transcriptConsumers.set(consumerId, consumer); @@ -337,90 +341,131 @@ export class RuntimeHostSessionObserver { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => - replica.loadBefore( + await this.#runTranscriptRangeOperation(request, targetId, async (replica, isCurrent) => { + const page = await replica.loadBefore( request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes), - token, - ), - ); + isCurrent, + ); + return page && { batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page), bytes: page.durable }; + }); } - async loadTranscriptAround( + async loadTranscriptAfter( request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => { - if (request.intent === 'followTail') { - return replica.followLatest(requireTranscriptRangeBytes(request.maxBytes), token); - } - if (request.readingTurnId !== undefined) { - return replica.readAt(request.anchorSequence, token, request.readingTurnId); - } - if (request.anchorSequence === null) { - throw new Error('Desktop transcript around request requires an anchor'); - } - if (request.preserveRange) return replica.readAt(request.anchorSequence, token); - return replica.loadAround( + await this.#runTranscriptRangeOperation(request, targetId, async (replica, isCurrent) => { + const page = await replica.loadAfter( request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes), - token, + isCurrent, ); + return page && { batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page), bytes: page.durable }; }); } - async loadTranscriptAfter( + async loadTranscriptAround( request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => - replica.loadAfter( - request.anchorSequence, + if (request.anchorSequence === null) { + throw new Error('Desktop transcript around request requires an anchor'); + } + const sequence = request.anchorSequence; + await this.#runTranscriptRangeOperation(request, targetId, async (replica, isCurrent) => { + const snapshot = await replica.loadAround( + sequence, requireTranscriptRangeBytes(request.maxBytes), - token, - ), - ); + isCurrent, + ); + return snapshot && { + batches: encodeDesktopTranscriptSnapshot({ ...snapshot, navigationVersion: request.navigationVersion }), + bytes: [...snapshot.durable, ...snapshot.overlay.map((message) => ({ message }))], + }; + }); } - async #runTranscriptRangeOperation( + async loadTranscriptLatest( request: DesktopTranscriptRangeRequest, - targetId: number | undefined, - operation: (replica: DesktopTranscriptReplica, token: number) => Promise, + targetId?: number, ): Promise { + const { state, consumer } = this.#admitTranscriptNavigation(request, targetId); + if (!consumer) return; + consumer.resetRequested = true; + await this.#scheduleTranscriptDelivery(state, consumer); + this.#touchReplica(state); + } + + #pageIdentity(replica: DesktopTranscriptReplica, request: DesktopTranscriptRangeRequest) { + return { + sessionId: replica.sessionId, + generation: replica.generation, + hostEpoch: replica.hostEpoch, + navigationVersion: request.navigationVersion, + }; + } + + #admitTranscriptNavigation( + request: DesktopTranscriptRangeRequest, + targetId: number | undefined, + ): { state: ObservedSessionState; replica: DesktopTranscriptReplica; consumer?: TranscriptConsumer } { const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); - const version = request.navigationVersion ?? consumer.navigationVersion; + const version = request.navigationVersion; if (!Number.isSafeInteger(version) || version < 0) throw new Error('Invalid transcript navigation version'); - if (version < consumer.navigationVersion) return; - if (request.intent !== undefined && request.intent !== 'history' && request.intent !== 'followTail') { - throw new Error('Invalid transcript navigation intent'); + // A window-replacing command mints a new version; a page that extends the + // current window reuses it. Anything older belongs to a window the + // Renderer already abandoned. + if (version < consumer.navigationVersion) return { state, replica }; + if (version > consumer.navigationVersion) { + consumer.navigationVersion = version; + consumer.pendingPages.splice(0).forEach((page) => + this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), + ); } - consumer.navigationVersion = version; - consumer.navigationRequest = request; - consumer.navigationPending = true; - consumer.resetRequested = true; - this.#clearPendingTranscriptChange(consumer); - // Admission invalidates in-flight pages immediately, before the replica's - // operation queue can run the newer command. - const token = replica.setNavigation(request.intent ?? 'history'); + return { state, replica, consumer }; + } + + async #runTranscriptRangeOperation( + request: DesktopTranscriptRangeRequest, + targetId: number | undefined, + operation: ( + replica: DesktopTranscriptReplica, + isCurrent: () => boolean, + ) => Promise< + | { batches: Iterable; bytes: readonly { readonly message: StoredMessage }[] } + | undefined + >, + ): Promise { + const { state, replica, consumer } = this.#admitTranscriptNavigation(request, targetId); + if (!consumer) return; const isCurrent = () => state.replica === replica && state.transcriptConsumers.get(request.consumerId) === consumer && - consumer.navigationRequest === request; + consumer.navigationVersion === request.navigationVersion; + let answer: Awaited>; try { - await operation(replica, token); - if (!isCurrent()) return; - consumer.navigationPending = false; - // Already dispatched batches remain ACKable; finish draining them before - // issuing the authoritative snapshot for this navigation. - await consumer.deliveryTask; - if (!isCurrent()) return; - consumer.resetRequested = true; - await this.#scheduleTranscriptDelivery(state, consumer); + answer = await operation(replica, isCurrent); } catch (error) { + // A replaced replica's failure is not a failure of the current window. if (!isCurrent()) return; - consumer.navigationPending = false; throw error; } + if (!answer || !isCurrent()) return; + const encodedBytes = answer.bytes.reduce( + (total, { message }) => total + encodedTranscriptMessageBytes(message), + 0, + ); + if (!this.#adjustTranscriptDeliveryBytes(consumer, encodedBytes)) { + throw new Error('Desktop transcript delivery capacity was reached'); + } + consumer.pendingPages.push({ + navigationVersion: request.navigationVersion, + generation: replica.generation, + batches: answer.batches, + encodedBytes, + }); + await this.#scheduleTranscriptDelivery(state, consumer); if (isCurrent()) this.#touchReplica(state); } @@ -1159,7 +1204,7 @@ export class RuntimeHostSessionObserver { this.#broadcast(state.sessionId, event); } this.#sendTranscriptChange(state, replica, change); - if (!change.hasNewer && change.durableUpserts.length > 0) { + if (change.durableUpserts.length > 0) { this.#markTranscriptRead(state, replica); } this.#touchReplica(state); @@ -1184,14 +1229,7 @@ export class RuntimeHostSessionObserver { #resetTranscriptConsumers(state: ObservedSessionState): void { for (const consumer of [...state.transcriptConsumers.values()]) { - const request = consumer.navigationRequest; - if (request && request.hostEpoch === state.replica?.hostEpoch) { - const recoveryRequest = { ...request, preserveRange: false }; - void this.loadTranscriptAround(recoveryRequest, consumer.target.id).catch(() => undefined); - } else { - consumer.navigationPending = false; - this.#requestTranscriptReset(state, consumer); - } + this.#requestTranscriptReset(state, consumer); } } @@ -1204,7 +1242,6 @@ export class RuntimeHostSessionObserver { task = (async () => { try { while (state.transcriptConsumers.get(consumer.consumerId) === consumer) { - if (consumer.navigationPending) return; if (consumer.resetRequested) { consumer.resetRequested = false; this.#clearPendingTranscriptChange(consumer); @@ -1228,6 +1265,21 @@ export class RuntimeHostSessionObserver { } continue; } + const page = consumer.pendingPages.shift(); + if (page) { + try { + if ( + page.navigationVersion === consumer.navigationVersion && + page.generation === consumer.generation && + state.replica?.generation === consumer.generation + ) { + await this.#sendTranscriptBatches(consumer, page.batches); + } + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes); + } + continue; + } const pending = consumer.pendingChange; if (!pending) return; consumer.pendingChange = undefined; @@ -1244,15 +1296,10 @@ export class RuntimeHostSessionObserver { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, - navigationVersion: consumer.navigationVersion, }, { durableThrough: pending.durableThrough, durableUpserts: [...pending.durableUpserts.values()].map(({ entry }) => entry), - evictedDurableSequences: [...pending.evictedDurableSequences], - completedOverlayMessageIds: [...pending.completedOverlayMessageIds], - hasOlder: pending.hasOlder, - hasNewer: pending.hasNewer, }, ), ); @@ -1288,42 +1335,16 @@ export class RuntimeHostSessionObserver { const pending = consumer.pendingChange ?? { durableThrough: change.durableThrough, durableUpserts: new Map(), - evictedDurableSequences: new Set(), - completedOverlayMessageIds: new Set(), - hasOlder: change.hasOlder, - hasNewer: change.hasNewer, encodedBytes: 0, }; let byteDelta = 0; pending.durableThrough = change.durableThrough; - pending.hasOlder = change.hasOlder; - pending.hasNewer = change.hasNewer; for (const entry of change.durableUpserts) { const previous = pending.durableUpserts.get(entry.sequence); if (previous) byteDelta -= previous.encodedBytes; const encodedBytes = encodedTranscriptMessageBytes(entry.message); pending.durableUpserts.set(entry.sequence, { entry, encodedBytes }); byteDelta += encodedBytes; - if (pending.evictedDurableSequences.delete(entry.sequence)) { - byteDelta -= encodedTranscriptIdentityBytes(entry.sequence); - } - } - for (const sequence of change.evictedDurableSequences) { - const previous = pending.durableUpserts.get(sequence); - if (previous) { - pending.durableUpserts.delete(sequence); - byteDelta -= previous.encodedBytes; - } - if (!pending.evictedDurableSequences.has(sequence)) { - pending.evictedDurableSequences.add(sequence); - byteDelta += encodedTranscriptIdentityBytes(sequence); - } - } - for (const messageId of change.completedOverlayMessageIds) { - if (!pending.completedOverlayMessageIds.has(messageId)) { - pending.completedOverlayMessageIds.add(messageId); - byteDelta += encodedTranscriptIdentityBytes(messageId); - } } pending.encodedBytes += byteDelta; consumer.pendingChange = pending; @@ -1419,7 +1440,7 @@ export class RuntimeHostSessionObserver { ): Promise { const deliveries = new Set>(); for (const batch of batches) { - if ((batch.navigationVersion ?? 0) !== consumer.navigationVersion || consumer.navigationPending) break; + if (batch.navigationVersion !== undefined && batch.navigationVersion !== consumer.navigationVersion) break; let delivery!: Promise; delivery = this.#deliverTranscriptBatch(consumer, batch).finally(() => { deliveries.delete(delivery); @@ -1476,6 +1497,9 @@ export class RuntimeHostSessionObserver { this.#transcriptConsumers.delete(consumer.consumerId); consumer.resetRequested = false; this.#clearPendingTranscriptChange(consumer); + consumer.pendingPages.splice(0).forEach((page) => + this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), + ); for (const pending of consumer.pendingDeliveries.values()) { pending.reject(new Error('Desktop transcript consumer was closed')); } @@ -1497,10 +1521,9 @@ export class RuntimeHostSessionObserver { for (const candidate of replicas) { if (total <= DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES) break; const before = candidate.replica.residentBytes; - const change = candidate.replica.trimDurable( + candidate.replica.trimDurable( Math.max(0, before - (total - DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES)), ); - if (change) this.#sendTranscriptChange(candidate.state, candidate.replica, change); total -= before - candidate.replica.residentBytes; } for (const candidate of replicas) { @@ -1546,10 +1569,6 @@ function encodedTranscriptMessageBytes(message: StoredMessage): number { return Buffer.byteLength(JSON.stringify(message), 'utf8'); } -function encodedTranscriptIdentityBytes(identity: number | string): number { - return Buffer.byteLength(JSON.stringify(identity), 'utf8'); -} - function requireTranscriptRangeBytes(value: number): number { if ( !Number.isSafeInteger(value) || diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2141c40c1a..1c1e1ee193 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2469,7 +2469,6 @@ const makaBridge = { const channel = `sessions:transcript:${consumerId}`; let identity: DesktopTranscriptIdentity | undefined; let cachedIdentity: DesktopTranscriptIdentity | undefined; - let navigationVersion = 0; const retiredGenerations = new Set(); let closed = false; let requestClose = () => {}; @@ -2489,7 +2488,7 @@ const makaBridge = { host.targetEpoch !== consumerScope.targetEpoch ) return; batch = assertDesktopTranscriptBatch(value); - if ((batch.navigationVersion ?? 0) === navigationVersion && !retiredGenerations.has(batch.generation)) { + if (!retiredGenerations.has(batch.generation)) { const adopted = adoptTranscriptIdentity(identity, batch); if (adopted !== identity) { if (identity && identity.generation !== adopted.generation) retiredGenerations.add(identity.generation); @@ -2556,6 +2555,7 @@ const makaBridge = { return { ...cachedIdentity, sessionId, readThroughMessageId: null, loadBefore: unavailable, loadAfter: unavailable, loadAround: unavailable, + loadLatest: unavailable, close: async () => {}, }; } @@ -2564,17 +2564,15 @@ const makaBridge = { if (closed) throw new Error('Desktop transcript open was cancelled'); identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch }; const range = ( - operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-after' | 'sessions:transcript:load-around', + operation: + | 'sessions:transcript:load-before' + | 'sessions:transcript:load-after' + | 'sessions:transcript:load-around' + | 'sessions:transcript:load-latest', anchorSequence: number | null, - maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation?: DesktopTranscriptNavigation, + maxBytes: number, + navigation: DesktopTranscriptNavigation, ): Promise => { - const nextNavigation = navigation ?? { - navigationVersion: navigationVersion + 1, - intent: 'history' as const, - }; - if (nextNavigation.navigationVersion < navigationVersion) return Promise.resolve(); - navigationVersion = nextNavigation.navigationVersion; const currentIdentity = identity; if (!currentIdentity) { throw new Error('Desktop transcript identity is unavailable'); @@ -2585,10 +2583,7 @@ const makaBridge = { hostEpoch: currentIdentity.hostEpoch, anchorSequence, maxBytes, - navigationVersion: nextNavigation.navigationVersion, - intent: nextNavigation.intent, - preserveRange: nextNavigation.preserveRange, - readingTurnId: nextNavigation.readingTurnId, + navigationVersion: navigation.navigationVersion, }) as Promise; }; return { @@ -2600,6 +2595,8 @@ const makaBridge = { range('sessions:transcript:load-after', anchorSequence, maxBytes, navigation), loadAround: (sequence, maxBytes, navigation) => range('sessions:transcript:load-around', sequence, maxBytes, navigation), + loadLatest: (navigation) => + range('sessions:transcript:load-latest', null, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, navigation), async close() { if (closed) return; requestClose(); diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index f729fefdce..b1d3450ac2 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -19,15 +19,13 @@ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; -export const DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS = 10; +/** Turns the Main tail cache keeps for the projector and for the tail the Renderer opens with. */ +export const DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; export interface DesktopTranscriptNavigation { readonly navigationVersion: number; - readonly intent: 'history' | 'followTail'; - readonly preserveRange?: boolean; - readonly readingTurnId?: string; } export interface DesktopTranscriptFragment { @@ -39,18 +37,21 @@ export interface DesktopTranscriptFragment { readonly data: Uint8Array; } +/** + * A batch answering a range command carries that command's version and the + * edge facts its page established. Broadcast batches (durable catch-up, cache + * trims) carry no version and no edge facts: the Renderer owns the window and + * applies them to whatever it holds. + */ export interface DesktopTranscriptBatchPayload { - /** An omitted version is zero, including cached bootstrap snapshots. */ readonly navigationVersion?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; readonly durableThrough: number | null; readonly fragments: readonly DesktopTranscriptFragment[]; - readonly evictedDurableSequences: readonly number[]; - readonly completedOverlayMessageIds: readonly string[]; - readonly hasOlder: boolean; - readonly hasNewer: boolean; + readonly hasOlder?: boolean; + readonly hasNewer?: boolean; readonly reset: boolean; readonly ready: boolean; } @@ -67,10 +68,7 @@ export interface DesktopTranscriptOpenResult { } export interface DesktopTranscriptRangeRequest { - readonly navigationVersion?: number; - readonly intent?: DesktopTranscriptNavigation['intent']; - readonly preserveRange?: boolean; - readonly readingTurnId?: string; + readonly navigationVersion: number; readonly consumerId: string; readonly sessionId: string; readonly hostEpoch: string; @@ -79,9 +77,10 @@ export interface DesktopTranscriptRangeRequest { } export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { - loadBefore(anchorSequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; - loadAfter(anchorSequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; - loadAround(sequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; + loadBefore(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptNavigation): Promise; + loadAfter(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptNavigation): Promise; + loadAround(sequence: number, maxBytes: number, navigation: DesktopTranscriptNavigation): Promise; + loadLatest(navigation: DesktopTranscriptNavigation): Promise; close(): Promise; } @@ -98,16 +97,8 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB typeof batch.hostEpoch !== 'string' || (batch.durableThrough !== null && !isSequence(batch.durableThrough)) || !Array.isArray(batch.fragments) || - !Array.isArray(batch.evictedDurableSequences) || - !batch.evictedDurableSequences.every(isSequence) || - batch.evictedDurableSequences.length > 256 || - !Array.isArray(batch.completedOverlayMessageIds) || - !batch.completedOverlayMessageIds.every( - (messageId) => typeof messageId === 'string' && messageId.length > 0 && messageId.length <= 256, - ) || - batch.completedOverlayMessageIds.length > 256 || - typeof batch.hasOlder !== 'boolean' || - typeof batch.hasNewer !== 'boolean' || + (batch.hasOlder !== undefined && typeof batch.hasOlder !== 'boolean') || + (batch.hasNewer !== undefined && typeof batch.hasNewer !== 'boolean') || typeof batch.reset !== 'boolean' || typeof batch.ready !== 'boolean' ) { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 56564eb0ef..cc8384715d 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -422,13 +422,16 @@ export function useActiveSessionEvents(options: { now: Date.now(), }), })); + const unsubscribeTranscript = transcript.subscribe(() => { + if (!disposed) applyTranscript(activeId, transcript, isDisposed); + }); const openTranscript = (signal: AbortSignal) => window.maka.transcripts.open( activeId, (batch) => { if (disposed) return; try { - if (transcript.accept(batch)) applyTranscript(activeId, transcript, isDisposed); + transcript.accept(batch); } catch (error) { applyReadError(activeId, error, isDisposed); } @@ -498,6 +501,7 @@ export function useActiveSessionEvents(options: { options.transcriptRangeRef.current = undefined; } void controller.close(); + unsubscribeTranscript(); unsubscribeSessionEvents(); markSessionEventStreamClosed(activeId); }; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5e8182eb33..4a11598a91 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -119,7 +119,6 @@ import type { DesktopSessionSummary, OnboardingSnapshot, } from '../preload/bridge-contract.js'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../preload/transcript-contract.js'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; @@ -2334,7 +2333,6 @@ function AppShellContent({ setTurnIndex={setTranscriptTurnIndex} listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} setHistoryPending={setHistoryLoadPending} - historyPageBytes={DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES} onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale), @@ -2691,7 +2689,8 @@ function AppShellContent({ hasOlderHistory={activeTranscriptRange?.hasOlder} hasNewerHistory={activeTranscriptRange?.hasNewer} historyLoadPending={historyLoadPending} - onLoadHistory={(target, anchorTurnId) => transcriptReadingCommands.current?.loadHistory(target, anchorTurnId)} + onLoadHistory={(target) => transcriptReadingCommands.current?.loadHistory(target)} + onRetainWindow={(window) => transcriptReadingCommands.current?.retainWindow(window)} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} transientMessages={transientMessages} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index df5534a0a8..f95d59db50 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -94,7 +94,8 @@ interface ChatMessageSurfaceProps extends Omit< hasOlderHistory?: boolean; hasNewerHistory?: boolean; historyLoadPending?: TranscriptHistoryPending; - onLoadHistory: (target: 'earlier' | 'later' | 'latest', anchorTurnId?: string) => Promise | void; + onLoadHistory: (target: 'earlier' | 'later' | 'latest') => Promise | void; + onRetainWindow: (window: { firstTurnId: string; lastTurnId: string }) => void; } function captureLiveContent(liveTurn: LiveTurnProjection | undefined) { @@ -131,6 +132,7 @@ export function ChatMessageSurface({ hasNewerHistory, historyLoadPending, onLoadHistory, + onRetainWindow, ...chatViewRest }: ChatMessageSurfaceProps) { const locale = useUiLocale(); @@ -252,8 +254,9 @@ export function ChatMessageSurface({ historyLoadPending={historyLoadPending && historyLoadPending.sessionId === activeSessionId ? historyLoadPending.target === 'earlier' ? 'older' : 'newer' : undefined} - onLoadEarlierHistory={(anchorTurnId) => onLoadHistory('earlier', anchorTurnId)} - onLoadLaterHistory={(anchorTurnId) => onLoadHistory('later', anchorTurnId)} + onLoadEarlierHistory={() => onLoadHistory('earlier')} + onLoadLaterHistory={() => onLoadHistory('later')} + onRetainWindow={onRetainWindow} /> )} diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx index 2f6dd72740..778683cbd1 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -24,20 +24,20 @@ import { captureTranscriptReadingAnchor, createTranscriptRestoreLifecycle, currentTranscriptRange, - loadTranscriptHistory, newestDurablePromptSequence, prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryGate, - type TranscriptHistoryGates, type TranscriptHistoryPending, - type TranscriptHistoryRequest, + type TranscriptHistoryTarget, } from './transcript-reading-position.js'; type RangeController = NonNullable>[0]['controller']> & { - loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; - loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; + readonly store: { + retain(oldestSequence: number | null, newestSequence: number | null): boolean; + }; + loadBefore(maxBytes?: number): Promise; + loadAfter(maxBytes?: number): Promise; loadLatest(): Promise; }; @@ -50,7 +50,8 @@ interface TurnIndex { export interface TranscriptReadingPositionCommands { prepareSend(sessionId: string): Promise; captureAnchor(turnId?: string): void; - loadHistory(target: TranscriptHistoryRequest['target'], anchorTurnId?: string): Promise; + loadHistory(target: TranscriptHistoryTarget): Promise; + retainWindow(window: { firstTurnId: string; lastTurnId: string }): void; } /** The conversation owns restoration lifetime; the shell supplies explicit ports. */ @@ -69,18 +70,16 @@ export function TranscriptReadingPositionController(props: { setTurnIndex: Dispatch>; listTurnLandmarks: Parameters>[0]['list']; setHistoryPending: Dispatch>; - historyPageBytes: number; onRestoreError(error: unknown, sessionId: string): void; onNavigationError(error: unknown, sessionId: string): void; }) { const [lifecycle] = useState(createTranscriptRestoreLifecycle); - const historyGates = useRef(new WeakMap()); + const lastLiveGeneration = useRef< + { sessionId: string; generation: string; hostEpoch: string } | undefined + >(undefined); const isCurrent = (sessionId: string, controller: object) => props.currentSessionId.current === sessionId && props.rangeController.current === controller; const cancelHistory = (sessionId: string) => { - const controller = props.rangeController.current; - if (currentTranscriptRange(controller, sessionId) === undefined) return; - if (controller) historyGates.current.delete(controller); props.setHistoryPending((current) => current?.sessionId === sessionId ? undefined : current); }; const cancel = (sessionId: string, clearAnchor = false) => { @@ -102,51 +101,44 @@ export function TranscriptReadingPositionController(props: { }, captureAnchor(turnId) { const { sessionId } = props; - const controller = props.rangeController.current; if (!sessionId || props.currentSessionId.current !== sessionId) return; - const previous = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; props.sessionUi.setTranscriptRestoreUnavailable(sessionId, undefined); captureTranscriptReadingAnchor({ - sessionId, currentSessionId: props.currentSessionId.current, turnId, controller, + sessionId, currentSessionId: props.currentSessionId.current, turnId, + controller: props.rangeController.current, setAnchor: props.sessionUi.setTranscriptReadingAnchor, }); - const range = currentTranscriptRange(controller, sessionId); - if (range === undefined) return; - const sequence = turnId ? controller?.store.sequenceForTurn(turnId) : undefined; - // The send command already cleared its bookmark before publishing the - // pin. Its empty-anchor acknowledgement is not another reader intent. - if (previous?.turnId === turnId && previous?.sequence === (sequence ?? undefined)) return; - let navigation: Promise | undefined; - cancelHistory(sessionId); - if (turnId) navigation = controller?.setReadingAnchor(sequence ?? null, turnId); - else if (!turnId && previous && !range.hasNewer) { - cancel(sessionId, true); - navigation = controller?.loadLatest(); + }, + retainWindow(window) { + const controller = props.rangeController.current; + const { sessionId } = props; + if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; + if (currentTranscriptRange(controller, sessionId) === undefined) return; + try { + controller.store.retain( + controller.store.sequenceForTurn(window.firstTurnId, 'first'), + controller.store.sequenceForTurn(window.lastTurnId, 'last'), + ); + } catch { + // A stale range has no window to trim. } - void navigation?.catch((error) => { - if (controller && isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); - }); }, - async loadHistory(target, anchorTurnId) { + async loadHistory(target) { const controller = props.rangeController.current; const { sessionId } = props; if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; cancel(sessionId, target === 'latest'); - // A direct latest command must enter the range controller now, so it - // invalidates older pages rather than waiting behind a paging gate. - if (target === 'latest' || historyGates.current.get(controller)?.active?.target === 'latest') { - cancelHistory(sessionId); + props.setHistoryPending({ sessionId, target }); + try { + if (target === 'latest') await controller.loadLatest(); + else if (target === 'earlier') await controller.loadBefore(); + else await controller.loadAfter(); + } catch (error) { + if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); + } finally { + props.setHistoryPending((current) => + current?.sessionId === sessionId && current.target === target ? undefined : current); } - const gates = historyGates.current; - const gate: TranscriptHistoryGate = gates.get(controller) ?? { pending: false }; - gates.set(controller, gate); - await loadTranscriptHistory({ - gates, sessionId, request: { target, anchorTurnId }, controller, - maxBytes: props.historyPageBytes, - isCurrent: () => isCurrent(sessionId, controller) && gates.get(controller) === gate, - setPending: props.setHistoryPending, - onError: (error) => props.onNavigationError(error, sessionId), - }); }, })); @@ -180,10 +172,27 @@ export function TranscriptReadingPositionController(props: { : undefined, controller: props.rangeController.current, isCurrent, - isLiveTurn: (sessionId, turnId) => props.sessionUi.liveTurnBySessionRef.current[sessionId]?.turnId === turnId, setReadingAnchor: props.sessionUi.setTranscriptReadingAnchor, onRestoreUnavailable: props.sessionUi.setTranscriptRestoreUnavailable, onError: props.onRestoreError, }), [props.sessionId, props.profileId, props.messages, props.searchTarget?.nonce]); + useEffect(() => { + const controller = props.rangeController.current; + const { sessionId } = props; + const range = currentTranscriptRange(controller, sessionId); + if (!controller || !sessionId || !range?.generation || !range.hostEpoch) return; + if (range.generation.startsWith('cached:')) return; + const previous = lastLiveGeneration.current; + lastLiveGeneration.current = { sessionId, generation: range.generation, hostEpoch: range.hostEpoch }; + if (!previous || previous.sessionId !== sessionId || previous.generation === range.generation) return; + // Sequences only mean the same rows within one Host epoch. + if (previous.hostEpoch !== range.hostEpoch) return; + const anchor = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; + if (anchor?.sequence === undefined) return; + if (controller.store.sequenceForTurn(anchor.turnId) !== null) return; + void controller.loadAround(anchor.sequence).catch((error) => { + if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); + }); + }, [props.sessionId, props.messages]); return null; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 7c12abf6bb..5b00d1fb0f 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -21,8 +21,13 @@ import type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; interface TranscriptRangeStore { readonly sessionId: string; - range(): { readonly sessionId: string; readonly hasNewer?: boolean }; - sequenceForTurn(turnId: string): number | null; + range(): { + readonly sessionId: string; + readonly hasNewer?: boolean; + readonly generation?: string; + readonly hostEpoch?: string; + }; + sequenceForTurn(turnId: string, edge?: 'first' | 'last'): number | null; newestDurableUserSequence(): number | null; snapshot(): { readonly messages: readonly Message[] }; } @@ -30,7 +35,6 @@ interface TranscriptRangeStore { interface TranscriptRangeController { readonly store: TranscriptRangeStore; loadAround(sequence: number): Promise; - setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; } interface SearchTarget { @@ -213,87 +217,11 @@ export function refreshTranscriptTurnLandmarks(options: { }; } -export interface TranscriptHistoryRequest { - readonly target: 'earlier' | 'later' | 'latest'; - readonly anchorTurnId?: string; -} +export type TranscriptHistoryTarget = 'earlier' | 'later' | 'latest'; export interface TranscriptHistoryPending { readonly sessionId: string; - readonly target: TranscriptHistoryRequest['target']; -} - -export interface TranscriptHistoryGate { - pending: boolean; - active?: TranscriptHistoryRequest; - queued?: TranscriptHistoryRequest; -} - -function updateTranscriptHistoryPending( - current: TranscriptHistoryPending | undefined, - sessionId: string, - request: TranscriptHistoryRequest | undefined, -): TranscriptHistoryPending | undefined { - if (request) return { sessionId, target: request.target }; - return current?.sessionId === sessionId ? undefined : current; -} - -/** One gate per controller: the shell rebuilds the controller per Session, so - * keying by it keeps Sessions from queuing behind each other's loads. */ -export type TranscriptHistoryGates = WeakMap; - -export async function loadTranscriptHistory(options: { - readonly gates: TranscriptHistoryGates; - readonly sessionId: string; - readonly request: TranscriptHistoryRequest; - readonly controller: { - loadBefore(maxBytes: number, anchorTurnId?: string): Promise; - loadAfter(maxBytes: number, anchorTurnId?: string): Promise; - loadLatest(): Promise; - }; - readonly maxBytes: number; - readonly isCurrent: () => boolean; - readonly setPending: ( - update: ( - current: TranscriptHistoryPending | undefined, - ) => TranscriptHistoryPending | undefined, - ) => void; - readonly onError: (error: unknown) => void; -}): Promise { - const { gates, controller, request } = options; - let gate = gates.get(controller) ?? { pending: false }; - gates.set(controller, gate); - if (gate.pending) { - // The scroller asks on every reader movement; dropping the request behind - // an in-flight load strands the reader until they move again. - if (request.target === 'latest' || gate.queued?.target !== 'latest') gate.queued = request; - return; - } - gate.pending = true; - gate.active = request; - options.setPending((current) => - updateTranscriptHistoryPending(current, options.sessionId, request)); - try { - if (request.target === 'latest') await controller.loadLatest(); - else await controller[request.target === 'earlier' ? 'loadBefore' : 'loadAfter']( - options.maxBytes, request.anchorTurnId, - ); - } catch (error) { - if (options.isCurrent()) options.onError(error); - } finally { - gate.pending = false; - gate.active = undefined; - // A send, search or explicit return to latest may replace this gate while - // its page is in flight. Its cleanup cannot clear the replacement's state - // or replay an older queued direction after the new navigation. - if (gates.get(controller) === gate && options.isCurrent()) { - options.setPending((current) => - updateTranscriptHistoryPending(current, options.sessionId, undefined)); - const queued = gate.queued; - gate.queued = undefined; - if (queued) void loadTranscriptHistory({ ...options, request: queued }); - } - } + readonly target: TranscriptHistoryTarget; } export function restoreSessionTranscriptRange(options: { @@ -304,7 +232,6 @@ export function restoreSessionTranscriptRange(options: { readonly readingAnchor?: TranscriptReadingAnchor; readonly controller?: TranscriptRangeController; readonly isCurrent: (sessionId: string, controller: TranscriptRangeController) => boolean; - readonly isLiveTurn?: (sessionId: string, turnId: string) => boolean; readonly setReadingAnchor: ( sessionId: string, anchor: TranscriptReadingAnchor | undefined, @@ -347,12 +274,11 @@ export function restoreSessionTranscriptRange(options: { const residentSequence = currentTranscriptRange(controller, sessionId) ? controller.store.sequenceForTurn(target.turnId) : null; - const sequence = residentSequence ?? target.sequence; - // Admit intent before awaiting the open handle. Resident and live-only - // targets retain their range while invalidating older navigation requests. - admitted = residentSequence !== null || sequence === undefined - ? controller.setReadingAnchor(sequence ?? null, target.turnId) - : controller.loadAround(sequence); + // A resident target needs no page: the scroller reveals it from the window + // the Renderer already holds. + admitted = residentSequence !== null || target.sequence === undefined + ? Promise.resolve() + : controller.loadAround(target.sequence); } catch (error) { admitted = Promise.reject(error); } @@ -366,7 +292,7 @@ export function restoreSessionTranscriptRange(options: { } return false; } - if (options.isLiveTurn?.(sessionId, target.turnId) || controller.store.snapshot().messages.some((message) => + if (controller.store.snapshot().messages.some((message) => message !== null && typeof message === 'object' && 'turnId' in message && message.turnId === target.turnId, )) { diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index a095efb448..b96d07a488 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -19,10 +19,8 @@ export { createTranscriptRestoreLifecycle, - loadTranscriptHistory, prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryGates, type TranscriptHistoryPending, } from './controller/transcript-reading-position.js'; diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index 8c9e0efb82..db637c2a0f 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -19,24 +19,30 @@ import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; -import type { - DesktopTranscriptBatchPayload, - DesktopTranscriptFragment, - DesktopTranscriptHandle, - DesktopTranscriptNavigation, +import { + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + type DesktopTranscriptBatchPayload, + type DesktopTranscriptFragment, + type DesktopTranscriptHandle, + type DesktopTranscriptNavigation, } from '../../../preload/transcript-contract.js'; import { projectDesktopStoredMessage } from '../../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; +/** + * The Renderer's window onto one Session transcript. `loadAround` and + * `loadLatest` replace the window and mint a navigation version; `loadBefore` + * and `loadAfter` extend it under the current version. Main answers the + * request and otherwise only broadcasts tail growth. + */ export interface DesktopTranscriptRangeController { readonly store: DesktopTranscriptRangeStore; ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; - loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; - loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; - loadAround(sequence: number): Promise; - setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; - loadLatest(maxBytes?: number): Promise; + loadBefore(maxBytes?: number): Promise; + loadAfter(maxBytes?: number): Promise; + loadAround(sequence: number, maxBytes?: number): Promise; + loadLatest(): Promise; reload(): Promise; close(): Promise; } @@ -48,40 +54,52 @@ export function createDesktopTranscriptRangeController( let closed = false; let openController = new AbortController(); let handle = open(openController.signal); - type Navigation = DesktopTranscriptNavigation & { - readonly kind: 'before' | 'after' | 'around' | 'latest' | 'anchor'; - readonly sequence: number | null; - readonly maxBytes?: number; - }; - let navigation: Navigation = { - navigationVersion: 0, intent: 'followTail', kind: 'latest', sequence: null, - }; + let navigationVersion = 0; + const extending: { older?: Promise; newer?: Promise } = {}; const current = async () => { if (closed) throw new Error('Desktop transcript range is closed'); return handle; }; - const dispatch = async (command: Navigation) => { + const command = async ( + replace: boolean, + run: (value: DesktopTranscriptHandle, navigation: DesktopTranscriptNavigation) => Promise, + ) => { + if (replace) { + navigationVersion += 1; + // Invalidate before awaiting an open handle or any in-flight page. + store.expectNavigation(navigationVersion); + } + const version = navigationVersion; const opening = handle; - const isCurrent = () => !closed && navigation === command && opening === handle; + const isCurrent = () => !closed && version === navigationVersion && opening === handle; try { const value = await current(); if (!isCurrent()) return; - if (command.kind === 'before') { - await value.loadBefore(command.sequence, command.maxBytes, command); - } else if (command.kind === 'after') { - await value.loadAfter(command.sequence, command.maxBytes, command); - } else { - await value.loadAround(command.sequence, command.maxBytes, command); - } + await run(value, { navigationVersion: version }); } catch (error) { if (isCurrent()) throw error; } }; - const navigate = (command: Omit) => { - navigation = { ...command, navigationVersion: navigation.navigationVersion + 1 }; - // Invalidate before awaiting an open handle or any previous page request. - store.expectNavigation(navigation.navigationVersion); - return dispatch(navigation); + const extend = (edge: 'older' | 'newer', maxBytes: number): Promise => { + const pending = extending[edge]; + if (pending) return pending; + let range: DesktopTranscriptRangeState; + try { + range = store.range(); + } catch { + return Promise.resolve(); + } + if (edge === 'older' ? !range.hasOlder : !range.hasNewer) return Promise.resolve(); + const anchor = edge === 'older' ? range.oldestSequence : range.newestSequence; + const task = command(false, (value, navigation) => + edge === 'older' + ? value.loadBefore(anchor, maxBytes, navigation) + : value.loadAfter(anchor, maxBytes, navigation), + ).finally(() => { + if (extending[edge] === task) extending[edge] = undefined; + }); + extending[edge] = task; + return task; }; return { store, @@ -90,48 +108,17 @@ export function createDesktopTranscriptRangeController( await current(); return store.waitForDurableMessage(messageId, timeoutMs); }, - async loadBefore(maxBytes, anchorTurnId) { - const range = store.range(); - const anchor = anchorTurnId === undefined ? undefined : store.sequenceForTurn(anchorTurnId); - if (anchor === null) { - await navigate({ intent: 'history', kind: 'anchor', sequence: null, - readingTurnId: anchorTurnId, preserveRange: true }); - return; - } - if (!range.hasOlder) return; - await navigate({ - intent: 'history', kind: 'before', maxBytes, - sequence: anchor ?? range.oldestSequence, - readingTurnId: anchorTurnId, - }); - }, - loadAround(sequence) { - return navigate({ intent: 'history', kind: 'around', sequence }); + loadBefore(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + return extend('older', maxBytes); }, - async loadAfter(maxBytes, anchorTurnId) { - const range = store.range(); - const anchor = anchorTurnId === undefined ? undefined : store.sequenceForTurn(anchorTurnId, 'last'); - if (anchor === null) { - await navigate({ intent: 'history', kind: 'anchor', sequence: null, - readingTurnId: anchorTurnId, preserveRange: true }); - return; - } - if (!range.hasNewer) return; - await navigate({ - intent: 'history', kind: 'after', maxBytes, - sequence: anchor ?? range.newestSequence, - readingTurnId: anchorTurnId, - }); + loadAfter(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + return extend('newer', maxBytes); }, - setReadingAnchor(sequence, readingTurnId) { - if (navigation.kind === 'anchor' && navigation.sequence === sequence && - navigation.readingTurnId === readingTurnId) { - return Promise.resolve(); - } - return navigate({ intent: 'history', kind: 'anchor', sequence, readingTurnId, preserveRange: true }); + loadAround(sequence, maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + return command(true, (value, navigation) => value.loadAround(sequence, maxBytes, navigation)); }, - loadLatest(maxBytes) { - return navigate({ intent: 'followTail', kind: 'latest', sequence: null, maxBytes }); + loadLatest() { + return command(true, (value, navigation) => value.loadLatest(navigation)); }, async reload() { const previous = handle; @@ -146,13 +133,6 @@ export function createDesktopTranscriptRangeController( }); handle = replacement; await replacement; - if (closed || handle !== replacement) return; - const command = navigation; - // An anchor notification needs a real range read on a replacement handle. - if (command.kind === 'anchor' || command.kind === 'before' || command.kind === 'after') { - navigation = { ...command, kind: 'around', preserveRange: false }; - } - await dispatch(navigation); }, async close() { if (closed) return; @@ -336,6 +316,7 @@ export class DesktopTranscriptRangeStore { #batchChanged = false; #snapshot: DesktopTranscriptRangeSnapshot | undefined; readonly #durableWaiters = new Set<() => void>(); + readonly #listeners = new Set<() => void>(); constructor(sessionKey: string) { const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); @@ -351,43 +332,55 @@ export class DesktopTranscriptRangeStore { this.#batchChanged = false; } + /** + * A batch answering a command is current only under the version that issued + * it. A reset for a new replica generation is always current: Main replaced + * the transcript underneath every window. Broadcasts carry no version. + */ accepts(batch: DesktopTranscriptBatchPayload): boolean { - // A stale reset must be rejected before it can clear the current range. - if ((batch.navigationVersion ?? 0) !== this.#navigationVersion) return false; if (this.#retiredGenerations.has(batch.generation)) return false; - return batch.reset || ( - batch.sessionId === this.#sourceSessionId && + if (batch.reset) { + return batch.navigationVersion === undefined || + batch.navigationVersion === this.#navigationVersion || + batch.generation !== this.#liveGeneration; + } + if (batch.navigationVersion !== undefined && batch.navigationVersion !== this.#navigationVersion) { + return false; + } + return batch.sessionId === this.#sourceSessionId && batch.generation === this.#generation && - batch.hostEpoch === this.#hostEpoch - ); + batch.hostEpoch === this.#hostEpoch; } accept(batch: DesktopTranscriptBatchPayload): boolean { if (!this.accepts(batch)) return false; if (batch.reset) this.#reset(batch); - let changed = - batch.reset || - batch.durableThrough !== this.#durableThrough || - batch.hasOlder !== this.#hasOlder || - batch.hasNewer !== this.#hasNewer; - this.#durableThrough = batch.durableThrough; - this.#hasOlder = batch.hasOlder; - this.#hasNewer = batch.hasNewer; - for (const sequence of batch.evictedDurableSequences) { - if (this.#durable.delete(sequence)) { - removeOrdered(this.#durableOrder, sequence); - this.#refreshSequenceBounds(sequence); - changed = true; - } + let changed = batch.reset; + const answersCommand = batch.navigationVersion !== undefined; + if (batch.hasOlder !== undefined && batch.hasOlder !== this.#hasOlder) { + this.#hasOlder = batch.hasOlder; + changed = true; } - for (const messageId of batch.completedOverlayMessageIds) { - if (this.#overlay.delete(messageId)) { - removeOrdered(this.#overlayOrder, messageId); + if (batch.hasNewer !== undefined) { + // A page read before the tail grew cannot close the window's newer edge; + // the rows that landed meanwhile were dropped below, so ask again. + const hasNewer = batch.hasNewer || + (batch.durableThrough !== null && this.#durableThrough !== null && + batch.durableThrough < this.#durableThrough); + if (hasNewer !== this.#hasNewer) { + this.#hasNewer = hasNewer; changed = true; } } + if (batch.durableThrough !== null && + (this.#durableThrough === null || batch.durableThrough > this.#durableThrough)) { + this.#durableThrough = batch.durableThrough; + changed = true; + } + // Tail growth belongs to the window only while the window reaches the tail. + const installDurable = answersCommand || !this.#hasNewer; for (const fragment of batch.fragments) { - changed = this.#acceptFragment(fragment) || changed; + changed = this.#acceptFragment(fragment, installDurable) || changed; } if (batch.ready && !this.#ready) { this.#ready = true; @@ -397,11 +390,49 @@ export class DesktopTranscriptRangeStore { if (!batch.ready) return false; const committed = this.#batchChanged; this.#batchChanged = false; - if (committed) this.#snapshot = this.#createSnapshot(); + if (committed) this.#commit(); for (const notify of this.#durableWaiters) notify(); return committed; } + /** Fires after every committed change to `snapshot()`. */ + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => { this.#listeners.delete(listener); }; + } + + /** + * Drops durable rows outside `[oldestSequence, newestSequence]`. Either edge + * that lost rows becomes a history edge again. + */ + retain(oldestSequence: number | null, newestSequence: number | null): boolean { + let changed = false; + for (const sequence of [...this.#durableOrder]) { + const older = oldestSequence !== null && sequence < oldestSequence; + const newer = newestSequence !== null && sequence > newestSequence; + if (!older && !newer) continue; + this.#durable.delete(sequence); + removeOrdered(this.#durableOrder, sequence); + if (older) this.#hasOlder = true; + else this.#hasNewer = true; + changed = true; + } + if (!changed) return false; + this.#oldestSequence = this.#durableOrder[0] ?? null; + this.#newestSequence = this.#durableOrder.at(-1) ?? null; + this.#newestUserSequence = null; + for (const sequence of this.#durableOrder) { + if (this.#durable.get(sequence)?.message.type === 'user') this.#newestUserSequence = sequence; + } + this.#commit(); + return true; + } + + #commit(): void { + this.#snapshot = this.#createSnapshot(); + for (const listener of [...this.#listeners]) listener(); + } + snapshot(): DesktopTranscriptRangeSnapshot { this.#snapshot ??= this.#createSnapshot(); return this.#snapshot; @@ -492,14 +523,14 @@ export class DesktopTranscriptRangeStore { this.#oldestSequence = null; this.#newestSequence = null; this.#newestUserSequence = null; - this.#hasOlder = batch.hasOlder; - this.#hasNewer = batch.hasNewer; + this.#hasOlder = batch.hasOlder ?? false; + this.#hasNewer = batch.hasNewer ?? false; this.#ready = false; this.#batchChanged = false; this.#snapshot = undefined; } - #acceptFragment(fragment: DesktopTranscriptFragment): boolean { + #acceptFragment(fragment: DesktopTranscriptFragment, installDurable: boolean): boolean { const key = `${fragment.source}:${typeof fragment.identity}:${fragment.identity}`; let pending = this.#pending.get(key); if (!pending) { @@ -534,23 +565,34 @@ export class DesktopTranscriptRangeStore { pending.bytes.set(bytes, fragment.byteOffset); pending.receivedBytes += bytes.byteLength; if (pending.receivedBytes < pending.totalBytes) return false; + this.#pending.delete(key); + // A declined row could still be the durable body of a message this window + // is showing as an overlay, which only its id can tell us. + if (pending.source === 'durable' && !installDurable && this.#overlay.size === 0) return false; const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); const message = freezeTranscriptValue(projectDesktopStoredMessage( { hostId: this.#hostId }, decodeStoredMessage(markPersisted(JSON.parse(encoded))), )); const projected = JSON.stringify(message); - this.#pending.delete(key); if (pending.source === 'durable') { if (!Number.isSafeInteger(pending.identity) || (pending.identity as number) < 0) { throw new Error('Invalid Desktop transcript durable identity'); } + const overlaid = this.#overlay.has(message.id); + if (!installDurable && !overlaid) return false; const sequence = pending.identity as number; const existing = this.#durable.get(sequence); if (existing && existing.encoded !== projected) { throw new Error('Desktop transcript durable record changed'); } - if (existing) return false; + // The durable row retires the overlay it settles, in the same step: a + // window that keeps one without the other loses the message. + if (overlaid) { + this.#overlay.delete(message.id); + removeOrdered(this.#overlayOrder, message.id); + } + if (existing) return overlaid; this.#durable.set(sequence, { message, encoded: projected }); insertOrdered(this.#durableOrder, sequence, (left, right) => left - right); this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); @@ -558,7 +600,7 @@ export class DesktopTranscriptRangeStore { if (message.type === 'user') { this.#newestUserSequence = Math.max(this.#newestUserSequence ?? sequence, sequence); } - return !existing; + return true; } if (typeof pending.identity !== 'string' || message.id !== pending.identity) { throw new Error('Desktop transcript overlay identity changed'); @@ -591,12 +633,6 @@ export class DesktopTranscriptRangeStore { return true; } - #refreshSequenceBounds(deletedSequence: number): void { - if (deletedSequence !== this.#oldestSequence && deletedSequence !== this.#newestSequence) return; - this.#oldestSequence = this.#durableOrder[0] ?? null; - this.#newestSequence = this.#durableOrder.at(-1) ?? null; - } - #createSnapshot(): DesktopTranscriptRangeSnapshot { const messages = Object.freeze([ ...this.#durableOrder.map((sequence) => this.#durable.get(sequence)!.message), diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index aa84d5cccd..0fa66eb5bc 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -52,6 +52,16 @@ overflow-anchor: auto; } +/* These rows mount and unmount around the reader without being anything the + reader is reading, so anchoring on one moves the transcript for no content: + a boundary gap, a send that is not durable yet, the placeholder held until + the live Turn exists. */ +.maka-transcript-gap-row, +[data-transient-message-id], +.maka-turn[data-live-streaming='true']:not([data-turn-id]) { + overflow-anchor: none; +} + .maka-transcript-turn { display: flex; width: 100%; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 16e4bd739a..25df5dc9d1 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2285,7 +2285,7 @@ export const SubmittedPromptSettlesWithoutReversing: Story = { /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; -/** Every `onLoadEarlierHistory` the transcript asked for, anchor turn first. */ +/** Every `onLoadEarlierHistory` the transcript asked for, oldest resident turn first. */ const historyLoads: string[] = []; const HISTORY_BATCH = 4; @@ -2313,7 +2313,13 @@ function SettledTranscriptHarness({ return ; } -/** The history seam is two props: `hasOlderHistory`, and a loader that prepends. */ +/** + * The history seam is two props: `hasOlderHistory`, and a loader that prepends. + * The loader settles a frame later, the way a page fetched over IPC does — the + * transcript reads the band again as soon as a page settles, so a loader that + * settled before its turns were laid out would be asked for the next page + * against the geometry of the previous one. + */ function HistoryHarness({ turns }: { turns: number }) { const [range, setRange] = useState({ from: 0, count: turns }); useEffect(() => { @@ -2324,23 +2330,36 @@ function HistoryHarness({ turns }: { turns: number }) { chat={{ messages: transcriptTurns(range.from, range.count), hasOlderHistory: range.from > -HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, - onLoadEarlierHistory: (anchorTurnId) => { - historyLoads.push(anchorTurnId ?? '(none)'); + onLoadEarlierHistory: async () => { + historyLoads.push(firstResidentTurnId() ?? '(none)'); setRange((current) => ({ from: current.from - HISTORY_BATCH, count: current.count + HISTORY_BATCH, })); + await painted(2); }, }} /> ); } -/** The band inside which the transcript treats a reader move as asking. */ +/** The band inside which the transcript keeps history loaded around the reader. */ function loadBand(): number { return Math.max(640, tailScroller().clientHeight * 2); } +/** History stops arriving once the band above the reader is full. */ +async function historySettled(): Promise { + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(loadBand()); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }, { timeout: 10_000 }); + const loads = historyLoads.length; + await painted(12); + expect(historyLoads.length, 'history kept arriving after the band was full').toBe(loads); +} + export const TailFollowsGrowthOutsideTurns: Story = { render: () => , play: async () => { @@ -2465,11 +2484,7 @@ export const DockAffordanceReturnsToTail: Story = { export const NestedScrollerNearHistoryBoundaryAsksForNothing: Story = { render: () => , play: async () => { - await waitFor(() => { - const settled = tailMetrics(); - expect(settled.scrollTop, JSON.stringify(settled)).toBeLessThanOrEqual(loadBand()); - expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); - }); + await historySettled(); const nested = injectNestedScroller(messageList()); await painted(6); @@ -2850,19 +2865,24 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { * assertions here are geometric. * * Tick count comes from `transcriptTurnIndex`, not from mounted Turns: the - * transcript holds only the Host's active range and the index carries the rest - * of the landmarks, so the rail gets all 64 ticks against 10 Turns. That is - * what the Host does in production. + * transcript holds a slice of the history and the index carries the rest of the + * landmarks, so the rail gets all 64 ticks against 10 Turns. That is what the + * Host does in production. */ const PROMPT_RAIL_TURN_COUNT = 120; -/** `DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS`, restated to keep stories off preload. */ -const PROMPT_RAIL_ACTIVE_RANGE = 10; +/** + * The Turns this story feeds the transcript — the tail a Session opens with, + * `DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS`, restated to keep stories off preload. + * What the reader ends up mounting is the Renderer's own retained band; this + * story never scrolls far enough to grow past its own slice. + */ +const PROMPT_RAIL_TAIL_TURNS = 10; /** `MAX_PROMPT_RAIL_TICKS` in prompt-anchor-rail.tsx, which does not export it. */ const PROMPT_RAIL_MAX_TICKS = 64; -const PROMPT_RAIL_TAIL_RANGE_START = PROMPT_RAIL_TURN_COUNT - PROMPT_RAIL_ACTIVE_RANGE + 1; +const PROMPT_RAIL_TAIL_RANGE_START = PROMPT_RAIL_TURN_COUNT - PROMPT_RAIL_TAIL_TURNS + 1; const promptRailIndex = Array.from({ length: PROMPT_RAIL_TURN_COUNT }, (_, offset) => ({ turnId: `turn-scroll-${offset + 1}`, @@ -2870,7 +2890,7 @@ const promptRailIndex = Array.from({ length: PROMPT_RAIL_TURN_COUNT }, (_, offse label: `第 ${offset + 1} 个问题`, })); -const promptRailMessages = transcriptTurns(PROMPT_RAIL_TAIL_RANGE_START, PROMPT_RAIL_ACTIVE_RANGE); +const promptRailMessages = transcriptTurns(PROMPT_RAIL_TAIL_RANGE_START, PROMPT_RAIL_TAIL_TURNS); function PromptRailHarness() { return ( @@ -3030,7 +3050,7 @@ export const ActiveTurnsKeepStableDomIdentities: Story = { const sourceCount = Number( messageList().getAttribute('data-turn-source-count'), ); - expect(sourceCount).toBe(PROMPT_RAIL_ACTIVE_RANGE); + expect(sourceCount).toBe(PROMPT_RAIL_TAIL_TURNS); expect(document.querySelectorAll('[data-turn-id]')).toHaveLength(sourceCount); // Marked on the elements themselves: a remount drops the attribute, which @@ -3132,7 +3152,7 @@ function PromptRailNavigationHarness() { return ( setFirstIndex(target.sequence), }} diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index 26eac1c61d..e27762aa59 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -23,356 +23,62 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { LocaleProvider } from '../locale-context.js'; import { - holdJumpDestination, mergePromptAnchorRailTurns, observeActivePromptRailVisibility, PromptAnchorRail, - selectPromptRailActiveTurn, - selectPromptRailTickForMountedTurn, - type PromptRailFrameScheduler, + selectPromptRailTick, } from '../prompt-anchor-rail.js'; - -/** - * The e2e suite cannot stage what these cover. Whether a jump survives depends - * on which frame the requested Host range lands, and the e2e case went green - * against a renderer that did not survive it. Driving the frames here makes it - * deterministic. - */ -function railHoldHarness() { - // The helper builds its selector with `CSS.escape`, which the renderer has - // and Node does not. Stubbed rather than worked around in the source: the - // escaping is what keeps a turn id safe inside a selector, and a fallback - // that only exists for tests would be the wrong thing to ship. - const priorCss = (globalThis as { CSS?: unknown }).CSS; - (globalThis as { CSS?: unknown }).CSS = { escape: (value: string) => value }; - - const frames: Array<() => void> = []; - const scheduler: PromptRailFrameScheduler = { - request: (callback) => { - frames.push(callback); - return frames.length; - }, - cancel: () => {}, - }; - const scrolled: Array = []; - const listeners = new Map(); - const state = { - scrollHeight: 1_000, - scrollTop: 900, - /** The target's top edge, relative to the scrollport's. 0 = landed. */ - targetTop: 600, - targetPresent: true, - queried: '', - settled: 0, - }; - const target = { - getBoundingClientRect: () => ({ top: state.targetTop }) as DOMRect, - scrollIntoView: (options?: ScrollIntoViewOptions) => { - scrolled.push(options); - // A real scroller lands the target at the top and moves to get there. - state.scrollTop += state.targetTop; - state.targetTop = 0; - }, - }; - const root = { - get scrollHeight() { - return state.scrollHeight; - }, - get scrollTop() { - return state.scrollTop; - }, - getBoundingClientRect: () => ({ top: 0 }) as DOMRect, - querySelector: (selector: string) => { - state.queried = selector; - return state.targetPresent ? target : null; - }, - addEventListener: (type: string, listener: EventListener) => listeners.set(type, listener), - removeEventListener: (type: string) => listeners.delete(type), - } as unknown as Element; - - return { - root, - scheduler, - scrolled, - state, - listeners, - /** Runs the frame the hold has queued, and only that one. */ - runFrame: (): void => frames.shift()?.(), - restore: (): void => { - (globalThis as { CSS?: unknown }).CSS = priorCss; - }, - }; -} - -test('a jump re-aims at its destination every time the transcript grows', () => { - const harness = railHoldHarness(); - const { root, scheduler, scrolled, state } = harness; - - const release = holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - state.scrollHeight = 1_400; - harness.runFrame(); - assert.equal(scrolled.length, 1, 'a fill step re-aims the jump'); - assert.deepEqual(scrolled[0], { behavior: 'auto', block: 'start' }); - assert.equal(state.queried, '[data-turn-id="turn-7"]'); - - harness.runFrame(); - assert.equal(scrolled.length, 1, 'a target already at the top is left alone'); - - // Every later fill step moves it again, and every one is followed back. - state.scrollHeight = 1_800; - state.targetTop = 320; - harness.runFrame(); - assert.equal(scrolled.length, 2, 'every later fill step re-aims too'); - - // A scroll that was cancelled part-way leaves the target off-target on an - // otherwise still frame. That is the other way a jump used to be lost. - state.targetTop = 240; - harness.runFrame(); - assert.equal(scrolled.length, 3, 'a stalled jump is corrected, not accepted'); - - release(); - harness.restore(); -}); - -test('a jump holds until the mounted destination is still', () => { - const harness = railHoldHarness(); - const { root, scheduler, state } = harness; - - holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - // Landed already, so nothing below is a correction — only the boundary. - state.targetTop = 0; - - harness.runFrame(); - harness.runFrame(); - assert.equal(state.settled, 0, 'settling waits for a few still frames'); - harness.runFrame(); - assert.equal(state.settled, 1, 'a mounted and still destination ends the hold'); - - harness.restore(); -}); - -test('a jump waits for an unloaded destination before settling', () => { - const harness = railHoldHarness(); - const { root, scheduler, state } = harness; - state.targetPresent = false; - - holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - for (let index = 0; index < 10; index += 1) harness.runFrame(); - assert.equal(state.settled, 0, 'a sparse transcript cannot settle before the target arrives'); - - state.targetPresent = true; - harness.runFrame(); - assert.equal(harness.scrolled.length, 1, 'the arriving target is placed at the top'); - harness.runFrame(); - harness.runFrame(); - harness.runFrame(); - assert.equal(state.settled, 1, 'the landed target settles normally'); - - harness.restore(); -}); - -test('a jump gives the transcript back the moment the reader touches it', () => { - const harness = railHoldHarness(); - const { root, scheduler, state, listeners } = harness; - - holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - assert.deepEqual( - [...listeners.keys()], - ['wheel', 'touchstart', 'pointerdown', 'keydown'], - 'every way a reader can take over is listened for', - ); - - state.targetTop = 0; - listeners.get('wheel')?.(new Event('wheel')); - assert.equal(state.settled, 1, 'a wheel ends the hold even mid-fill'); - assert.equal(listeners.size, 0, 'and the hold stops listening'); - - // Whatever the transcript does next is no longer the jump's business. - state.scrollHeight = 9_000; - harness.runFrame(); - assert.equal(harness.scrolled.length, 0, 'a released hold does not re-aim'); - assert.equal(state.settled, 1, 'and settles exactly once'); - - harness.restore(); -}); - -const turnIndexById = new Map([ - ['turn-1', 0], - ['turn-2', 1], - ['turn-3', 2], - ['turn-4', 3], -]); - -test('the transcript tail selects the latest mounted Turn', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: true, - mountedTurnIds: ['turn-3', 'turn-1', 'turn-4'], - readingBandTurnIds: ['turn-1'], - scrollportTurnIds: ['turn-1', 'turn-3'], - turnIndexById, - }), 'turn-4'); +import { TranscriptScrollAuthorityProvider } from '../transcript-scroll-authority.js'; + +const orderedTurnIds = Array.from({ length: 120 }, (_, index) => `turn-${index + 1}`); +const sampledRailTurnIds = Array.from({ length: 64 }, (_, railIndex) => + orderedTurnIds[Math.round(railIndex * 119 / 63)]!, +); + +test('a sampled rail projects the reading Turn onto the tick that stands for it', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: 'turn-67', + orderedTurnIds, + railTurnIds: sampledRailTurnIds, + previousRailTurnId: null, + }), 'turn-67', 'a sampled Turn is its own tick'); + // 120 Turns over 64 ticks: turn-66 has none of its own. + assert.equal(sampledRailTurnIds.includes('turn-66'), false); + assert.equal(selectPromptRailTick({ + readingTurnId: 'turn-66', + orderedTurnIds, + railTurnIds: sampledRailTurnIds, + previousRailTurnId: null, + }), 'turn-65'); }); -test('the reading band selects its earliest Turn by transcript order', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: false, - mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], - readingBandTurnIds: ['turn-4', 'turn-2', 'turn-3'], - scrollportTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], - turnIndexById, +test('every Turn is its own tick while the rail is under its cap', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: 'turn-2', + orderedTurnIds: ['turn-1', 'turn-2', 'turn-3'], + railTurnIds: ['turn-1', 'turn-2', 'turn-3'], + previousRailTurnId: 'turn-1', }), 'turn-2'); }); -test('an empty reading band falls back to the earliest Turn in the scrollport', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: false, - mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], - readingBandTurnIds: [], - scrollportTurnIds: ['turn-4', 'turn-3'], - turnIndexById, - }), 'turn-3'); +test('a reading position the rail has no landmark for keeps the current tick', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: 'unindexed-turn', + orderedTurnIds: ['turn-1', 'turn-2', 'turn-3'], + railTurnIds: ['turn-1', 'turn-2', 'turn-3'], + previousRailTurnId: 'turn-2', + }), 'turn-2'); }); -test('no eligible Turn leaves the selection unresolved', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: false, - mountedTurnIds: ['unknown-turn'], - readingBandTurnIds: [], - scrollportTurnIds: [], - turnIndexById, +test('no reading position and no usable current tick leaves the rail unmarked', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: undefined, + orderedTurnIds: ['turn-1', 'turn-2', 'turn-3'], + railTurnIds: ['turn-1', 'turn-2', 'turn-3'], + previousRailTurnId: 'turn-from-another-session', }), null); }); -test('an unsampled mounted Turn maps through the two nearest durable landmarks', () => { - const railTurns = Array.from({ length: 64 }, (_, railIndex) => { - const turnIndex = Math.round(railIndex * 119 / 63); - return { turnId: `turn-${turnIndex + 1}`, label: '', sequence: turnIndex * 2 }; - }); - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'turn-66', - mountedTurnIds: ['turn-66', 'turn-67', 'turn-68', 'turn-69'], - railTurns, - previousRailTurnId: 'turn-67', - atEnd: false, - }), 'turn-65'); -}); - -test('uneven sequence gaps choose a nearby real landmark instead of a linear tick position', () => { - const railTurns = [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - { turnId: 'turn-c', label: '', sequence: 1_000 }, - { turnId: 'turn-d', label: '', sequence: 1_010 }, - ]; - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['turn-b', 'active', 'turn-c'], - railTurns, - previousRailTurnId: 'turn-b', - atEnd: false, - }), 'turn-c'); -}); - -test('one-sided sequence extrapolation cannot skip past the adjacent tick', () => { - const railTurns = [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - { turnId: 'turn-c', label: '', sequence: 1_000 }, - { turnId: 'turn-d', label: '', sequence: 1_010 }, - ]; - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active', 'turn-b', 'turn-c'], - railTurns, - previousRailTurnId: 'turn-d', - atEnd: false, - }), 'turn-a'); -}); - -test('a prompt-less mounted tail uses the nearest loaded prompt before the index arrives', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['turn-a', 'turn-b', 'active'], - railTurns: [ - { turnId: 'turn-a', label: '' }, - { turnId: 'turn-b', label: '' }, - ], - previousRailTurnId: null, - atEnd: true, - }), 'turn-b'); -}); - -test('a prompt-less tail without a mounted landmark uses the final rail tick', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active'], - railTurns: [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - ], - previousRailTurnId: null, - atEnd: true, - }), 'turn-b'); -}); - -test('a window without a sampled wrapper preserves its previous current tick', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active', 'neighbor'], - railTurns: [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - ], - previousRailTurnId: 'turn-a', - atEnd: false, - }), 'turn-a'); -}); - -test('a window without landmarks replaces a stale current with a current rail tick', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active'], - railTurns: [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - ], - previousRailTurnId: 'stale-turn', - atEnd: false, - }), 'turn-a'); -}); - test('keeps the active tick visible when the rail viewport resizes', () => { let railBox = box(0, 600); let tickBox = box(570, 590); @@ -484,13 +190,17 @@ test('updates landmark content when its body enters a later resident range', () test('keeps unloaded landmarks visually uniform and actionable', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', - children: createElement(PromptAnchorRail, { - turns: [ - { turnId: 'turn-1', label: 'Prompt 1', sequence: 0 }, - { turnId: 'turn-2', label: 'Prompt 2', sequence: 2 }, - { turnId: 'turn-3', label: 'Prompt 3', sequence: 4 }, - ], - scrollRef: { current: null }, + // The rail reads its current tick from the scroll authority, so it only + // renders under one — the same contract ChatView states about its layout. + children: createElement(TranscriptScrollAuthorityProvider, { + children: createElement(PromptAnchorRail, { + turns: [ + { turnId: 'turn-1', label: 'Prompt 1', sequence: 0 }, + { turnId: 'turn-2', label: 'Prompt 2', sequence: 2 }, + { turnId: 'turn-3', label: 'Prompt 3', sequence: 4 }, + ], + scrollRef: { current: null }, + }), }), })); diff --git a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx similarity index 50% rename from packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx rename to packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx index 7680f0d5f0..3b3856977e 100644 --- a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx +++ b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx @@ -18,23 +18,9 @@ */ /** - * The prompt rail observes every mounted Turn in the transcript. What that - * observer is for is Turn identity and order, and a streaming answer delivers - * several deltas a second that change neither — so a delta must not tear the - * observer down and rebuild it over the whole conversation. - * - * Two things hold that in series: ChatView hands the rail the previous entry - * array back when no persisted prompt or answer text moved, and the rail keys - * the observer's lifetime on the Turn id list rather than on its props. Either - * one alone keeps the count at 1, which is why this asserts the composed - * outcome the way the deleted E2E case did rather than probing one of them. - * - * A probe, not a story: this counts constructions and reads the init a - * constructor was handed, and both are only observable from before the rail's - * own observer exists. Installing `IntersectionObserver` on the global here is - * what makes the positive control real — a story mounting the rail first can - * only watch the observer it already has, so the `rootMargin` assertion below - * would pass against any literal. + * The rail's current tick is the reading position the scroll authority + * publishes, and nothing else. Mounted through the real layout, because that + * is what hands the authority the scroller the reader scrolls. */ import assert from 'node:assert/strict'; @@ -47,14 +33,12 @@ import { AstryxLocaleProvider } from '../astryx-i18n.js'; import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import { LocaleProvider } from '../locale-context.js'; -import { READING_BAND_TOP_PERCENT } from '../prompt-anchor-rail.js'; const originalGlobals = { CSS: globalThis.CSS, document: globalThis.document, Element: globalThis.Element, HTMLElement: globalThis.HTMLElement, - IntersectionObserver: globalThis.IntersectionObserver, MutationObserver: globalThis.MutationObserver, Node: globalThis.Node, ResizeObserver: globalThis.ResizeObserver, @@ -79,6 +63,8 @@ afterEach(async () => { }); const TURN_COUNT = 6; +const TURN_HEIGHT = 400; +const SCROLLPORT_HEIGHT = 600; const activeSession: SessionSummary = { id: 'session-rail', @@ -97,7 +83,7 @@ const activeSession: SessionSummary = { permissionMode: 'ask', }; -function turnMessages(answerText: (index: number) => string): StoredMessage[] { +function turnMessages(): StoredMessage[] { return Array.from({ length: TURN_COUNT }, (_, index): StoredMessage[] => [ { type: 'user', @@ -111,56 +97,46 @@ function turnMessages(answerText: (index: number) => string): StoredMessage[] { id: `assistant-${index}`, turnId: `turn-${index}`, ts: index * 2 + 1, - text: answerText(index), + text: '答案', modelId: 'claude-sonnet-4-5', }, ]).flat(); } -interface ObservedInit { - root: unknown; - rootMargin?: string; - threshold?: number | number[]; +function view(messages: StoredMessage[]): ReactElement { + const chat = createElement(ChatView, { messages, activeSession, onNew: () => {} } as never); + const layout = createElement(ChatSurfaceLayout, { + composer: null, + children: chat, + }); + const astryx = createElement(AstryxLocaleProvider, { children: layout }); + return createElement(LocaleProvider, { locale: 'zh-CN', children: astryx }); } +/** linkedom lays nothing out, so every box this reads is stated here. */ function harness() { const { document, window } = parseHTML('
'); - const inits: ObservedInit[] = []; - class CountingIntersectionObserver { - constructor(_callback: IntersectionObserverCallback, init?: IntersectionObserverInit) { - inits.push({ - root: init?.root, - rootMargin: init?.rootMargin, - threshold: init?.threshold as number | number[] | undefined, - }); - } - observe(): void {} - unobserve(): void {} - disconnect(): void {} - takeRecords(): IntersectionObserverEntry[] { - return []; - } - } + const viewport = { scrollTop: 0 }; + const scrollport = { + bottom: SCROLLPORT_HEIGHT, height: SCROLLPORT_HEIGHT, left: 0, right: 800, top: 0, + width: 800, x: 0, y: 0, toJSON: () => ({}), + } satisfies DOMRect; + window.Element.prototype.getBoundingClientRect = function (this: Element): DOMRect { + const turnId = this.getAttribute('data-turn-id'); + if (turnId === null) return scrollport; + const top = Number(turnId.split('-')[1]) * TURN_HEIGHT - viewport.scrollTop; + return { ...scrollport, top, bottom: top + TURN_HEIGHT, height: TURN_HEIGHT }; + }; class InertResizeObserver { observe(): void {} unobserve(): void {} disconnect(): void {} } - // linkedom lays nothing out, so every box is zero-sized. The rail reads - // geometry only to pick which tick is current; the observer it builds to do - // that is what this test is about, and a constructor call does not need a - // layout to be counted. - const rect = { - bottom: 600, height: 600, left: 0, right: 800, top: 0, width: 800, x: 0, y: 0, - toJSON: () => ({}), - } satisfies DOMRect; - window.Element.prototype.getBoundingClientRect = () => rect; Object.assign(globalThis, { - CSS: { supports: () => false }, + CSS: { supports: () => false, escape: (value: string) => value }, document, Element: window.Element, HTMLElement: window.HTMLElement, - IntersectionObserver: CountingIntersectionObserver, MutationObserver: window.MutationObserver, Node: window.Node, ResizeObserver: InertResizeObserver, @@ -179,62 +155,51 @@ function harness() { }); const mount = document.querySelector('#mount'); assert.ok(mount); - return { inits, mount }; + return { + mount, + window, + viewport, + /** Give the mounted scroller the geometry a scrolled transcript has. */ + scroller(): HTMLElement { + const element = document.querySelector('[data-chat-scroll-container]'); + assert.ok(element, 'the layout publishes the scroller the authority attaches to'); + Object.defineProperties(element, { + scrollTop: { + get: () => viewport.scrollTop, + set: (value: number) => { viewport.scrollTop = value; }, + }, + scrollHeight: { get: () => TURN_COUNT * TURN_HEIGHT }, + clientHeight: { get: () => SCROLLPORT_HEIGHT }, + }); + return element; + }, + }; } -function view(messages: StoredMessage[]): ReactElement { - const chat = createElement(ChatView, { messages, activeSession, onNew: () => {} } as never); - const layout = createElement(ChatSurfaceLayout, { - composer: null, - children: chat, - }); - const astryx = createElement(AstryxLocaleProvider, { children: layout }); - return createElement(LocaleProvider, { locale: 'zh-CN', children: astryx }); +function activeTickTurnId(mount: HTMLElement): string | null { + return mount.querySelector('.maka-prompt-rail-tick[data-active="true"]') + ?.getAttribute('data-prompt-turn-id') ?? null; } -test('streaming deltas do not reconstruct the prompt rail observer', async () => { - const { inits, mount } = harness(); - const root = createRoot(mount); +test('the current tick follows the reading position the authority publishes', async () => { + const probe = harness(); + const root = createRoot(probe.mount); mountedRoot = root; - await act(() => { - root.render(view(turnMessages(() => '答案'))); + root.render(view(turnMessages())); }); - assert.equal(inits.length, 1, 'the rail observes the transcript once on mount'); - - // Ten deltas on the tail answer. Every one of them hands ChatView a fresh - // message array and fresh turn records — which is exactly the shape that - // used to rebuild the observer over the whole transcript per frame. - for (let delta = 1; delta <= 10; delta += 1) { - await act(() => { - root.render( - view( - turnMessages((index) => - index === TURN_COUNT - 1 ? `答案${'。'.repeat(delta)}` : '答案', - ), - ), - ); - }); - } - assert.equal(inits.length, 1, `the observer was rebuilt ${inits.length - 1} times`); -}); + const scroller = probe.scroller(); -test('the rail observes its reading band, not the whole scrollport', async () => { - const { inits, mount } = harness(); - const root = createRoot(mount); - mountedRoot = root; + // The reader takes the transcript to the third Turn's box. await act(() => { - root.render(view(turnMessages(() => '答案'))); + probe.viewport.scrollTop = TURN_HEIGHT * 2 + 100; + scroller.dispatchEvent(new probe.window.Event('scroll')); }); + assert.equal(activeTickTurnId(probe.mount), 'turn-2'); - const init = inits[0]; - assert.ok(init); - // The band is the top slice of the scrollport, so the bottom inset is its - // complement. Both spellings come from one constant; a rail that observed - // the whole scrollport would call every Turn on screen "being read". - assert.equal(init.rootMargin, `0px 0px -${100 - READING_BAND_TOP_PERCENT}% 0px`); - assert.ok(READING_BAND_TOP_PERCENT > 0 && READING_BAND_TOP_PERCENT < 100); - // Zero alone reports a boundary touch as an intersection; the second, - // positive threshold is what distinguishes real overlap from that. - assert.deepEqual(init.threshold, [0, 0.000_001]); + await act(() => { + probe.viewport.scrollTop = TURN_HEIGHT * 4; + scroller.dispatchEvent(new probe.window.Event('scroll')); + }); + assert.equal(activeTickTurnId(probe.mount), 'turn-4'); }); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index a0034c82b4..b5e79bfdb2 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -23,6 +23,13 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; +interface FakeTurn { + turnId: string; + /** Offset within the scrolled content, which `scrollTop` then shifts. */ + top: number; + height: number; +} + interface FakeRoot { ownerDocument: EventTarget; style: { overflowAnchor: string }; @@ -31,6 +38,10 @@ interface FakeRoot { clientHeight: number; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; + /** Mounted Turns, laid out relative to `scrollTop`. */ + turns: FakeTurn[]; + getBoundingClientRect(): DOMRect; + querySelectorAll(selector: string): readonly unknown[]; addEventListener(type: string, listener: (event: unknown) => void): void; removeEventListener(type: string, listener: (event: unknown) => void): void; input(deltaY: number, modifiers?: { ctrlKey?: boolean; metaKey?: boolean }): void; @@ -55,6 +66,15 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], + turns: [], + getBoundingClientRect: () => ({ top: 0 }) as DOMRect, + querySelectorAll: () => root.turns.map((turn) => ({ + getAttribute: () => turn.turnId, + getBoundingClientRect: () => ({ + top: turn.top - root.scrollTop, + bottom: turn.top + turn.height - root.scrollTop, + }) as DOMRect, + })), addEventListener(type, listener) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type)!.add(listener); @@ -99,8 +119,9 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F * * End-of-operation frame callbacks are advanced explicitly. */ -function withObservers(run: (resize: () => void, frame: () => void) => T): T { +function withObservers(run: (resize: () => void, frame: () => void, mutate: () => void) => T): T { const observers = new Set<() => void>(); + const mutations = new Set<() => void>(); const frames: FrameRequestCallback[] = []; const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; const originalResize = globals.ResizeObserver; @@ -122,13 +143,20 @@ function withObservers(run: (resize: () => void, frame: () => void) => T): T // The set of children only changes when the transcript mounts or unmounts // one, and `resize` already stands for every box in that set changing. globals.MutationObserver = class { - observe(): void {} - disconnect(): void {} + constructor(private readonly callback: () => void) {} + observe(): void { + mutations.add(this.callback); + } + disconnect(): void { + mutations.delete(this.callback); + } }; try { return run(() => { for (const observer of [...observers]) observer(); - }, () => { for (const callback of frames.splice(0)) callback(0); }); + }, () => { for (const callback of frames.splice(0)) callback(0); }, () => { + for (const mutation of [...mutations]) mutation(); + }); } finally { globals.ResizeObserver = originalResize; globals.MutationObserver = originalMutation; @@ -457,6 +485,57 @@ test('content landing above a released reader does not re-pin them', () => { }); }); +test('the reading position names the Turn crossing the top of the scrollport', () => { + withObservers((resize, _frame, mutate) => { + const root = fakeRoot(); + root.turns = [ + { turnId: 'turn-1', top: 0, height: 1_000 }, + { turnId: 'turn-2', top: 1_000, height: 1_000 }, + { turnId: 'turn-3', top: 2_000, height: 1_000 }, + ]; + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + let publications = 0; + authority.subscribe(() => { publications += 1; }); + + // Attached pinned, so the authority wrote the tail under the reader. + assert.equal(root.scrollTop, 2_400); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-3'); + + root.input(-100); + root.scrollTop = 1_200; + root.emitScroll(); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-2'); + assert.ok(publications > 0, 'a new reading position is published'); + + // Same Turn still under the top edge: nothing new to say. + const published = publications; + root.scrollTop = 1_400; + root.emitScroll(); + assert.equal(publications, published); + + // A Turn arriving above the reader moves the position without the reader. + for (const turn of root.turns) turn.top += 500; + root.turns.unshift({ turnId: 'turn-0', top: 0, height: 500 }); + root.grow(500); + root.scrollTop = 1_900; + mutate(); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-2'); + root.scrollTop = 400; + resize(); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-0'); + }); +}); + +test('a transcript without Turns has no reading position', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(authority.getSnapshot().readingTurnId, undefined); + }); +}); + test('only the reader\'s own movement reaches a reader-scroll listener', () => { withObservers(() => { const root = fakeRoot(); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 70759d1579..377af2166f 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -51,7 +51,10 @@ let mountedRoot: ReturnType | undefined; function wheel(target: HTMLElement, deltaY: number): void { const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(event, 'deltaY', { value: deltaY }); + Object.defineProperties(event, { + deltaY: { value: deltaY }, + composedPath: { value: () => [target] }, + }); target.dispatchEvent(event); } @@ -111,6 +114,7 @@ const installScrollTestEnvironment = ( document, Element: window.Element, HTMLElement: window.HTMLElement, + getComputedStyle: () => ({ overflowY: 'visible' }), MutationObserver: TestMutationObserver, Node: window.Node, ResizeObserver: TestResizeObserver, @@ -121,94 +125,285 @@ const installScrollTestEnvironment = ( return { frames, resizeCallbacks }; }; -test('pages only toward reader input, including wheels at a bounded edge', async () => { - const { document, window } = parseHTML('
'); - const scroller = document.querySelector('#scroller')!; +function boxOf(top: number, bottom: number): DOMRect { + return { + top, + bottom, + height: bottom - top, + left: 0, + right: 800, + width: 800, + x: 0, + y: top, + toJSON: () => undefined, + } as DOMRect; +} + +/** + * A scroller of `turnCount` equal Turns whose geometry the test drives. Turn + * boxes are derived from the current offset, so a scroll moves every box the + * way a real one does. + */ +function createTranscript( + document: Document, + window: ReturnType['window'], + options: { clientHeight: number; turnHeight: number; turnCount: number }, +) { + const scroller = document.querySelector('#scroller'); + assert.ok(scroller); let scrollTop = 0; + let turnCount = options.turnCount; + const contentHeight = (): number => + Math.max(options.clientHeight, turnCount * options.turnHeight); Object.defineProperties(scroller, { - clientHeight: { value: 600 }, - scrollHeight: { value: 2400 }, + clientHeight: { value: options.clientHeight }, + scrollHeight: { get: () => contentHeight() }, scrollTop: { get: () => scrollTop, - set: (value: number) => { scrollTop = Math.max(0, Math.min(value, 1800)); }, + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, contentHeight() - options.clientHeight)); + }, }, }); - scroller.getBoundingClientRect = () => ({ top: 0, bottom: 600 } as DOMRect); - for (let index = 0; index < 4; index++) { - const turn = document.createElement('article'); - turn.dataset.turnId = `turn-${index}`; - turn.getBoundingClientRect = () => ({ - top: index * 600 - scrollTop, bottom: (index + 1) * 600 - scrollTop, - } as DOMRect); - scroller.append(turn); - } - class Observer { disconnect() {} observe() {} } - Object.assign(globalThis, { - document, window, HTMLElement: window.HTMLElement, Element: window.Element, - MutationObserver: Observer, ResizeObserver: Observer, - getComputedStyle: () => ({ overflowY: 'auto' }), - IS_REACT_ACT_ENVIRONMENT: true, + scroller.getBoundingClientRect = () => boxOf(0, options.clientHeight); + const install = (): void => { + scroller.replaceChildren(); + for (let index = 0; index < turnCount; index += 1) { + const turn = document.createElement('article'); + turn.dataset.turnId = `turn-${index}`; + const start = index * options.turnHeight; + turn.getBoundingClientRect = () => + boxOf(start - scrollTop, start + options.turnHeight - scrollTop); + turn.scrollIntoView = () => { scroller.scrollTop = start; }; + scroller.append(turn); + } + }; + install(); + return { + scroller, + get scrollTop(): number { return scrollTop; }, + setTurnCount(next: number): void { + turnCount = next; + install(); + // A real scroller clamps its offset the moment its content shrinks. + scroller.scrollTop = scrollTop; + }, + /** A reader gesture and the scroll it produces, in that order. */ + readerScrollTo(top: number): void { + const delta = top - scrollTop; + if (delta === 0) return; + wheel(scroller, delta); + scroller.scrollTop = top; + scroller.dispatchEvent(new window.Event('scroll')); + }, + }; +} + +test('history loads follow the reader band, in both directions, once per direction', async () => { + const { document, window } = parseHTML( + '
', + ); + installScrollTestEnvironment(document, window, { queueFrames: false }); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 4, }); - const calls: Array<{ direction: string; anchor?: string }> = []; - let authority!: TranscriptScrollAuthority; - function Harness({ more }: { more: boolean }) { - const scrollRef = useRef(scroller); - authority = useTranscriptScrollAuthority(); + + const calls: string[] = []; + const resolvers: Array<() => void> = []; + const load = (direction: string) => (): Promise => { + calls.push(direction); + return new Promise((resolve) => resolvers.push(resolve)); + }; + let history = { older: true, newer: true }; + function Harness({ older, newer }: { older: boolean; newer: boolean }) { + const scrollRef = useRef(transcript.scroller); useChatScroll({ - scrollRef, sessionId: 'guest', messages: [], behavior: 'auto', - hasOlderHistory: more, hasNewerHistory: more, - onLoadEarlierHistory: (anchor) => { calls.push({ direction: 'up', anchor }); }, - onLoadLaterHistory: (anchor) => { calls.push({ direction: 'down', anchor }); }, + scrollRef, + sessionId: 'session-band', + messages: [{ id: 'message-1' }] as StoredMessage[], + behavior: 'auto', + hasOlderHistory: older, + hasNewerHistory: newer, + onLoadEarlierHistory: load('up'), + onLoadLaterHistory: load('down'), }); return null; } mountedRoot = createRoot(document.querySelector('#mount')!); - const render = async (more: boolean) => act(() => mountedRoot!.render( - , + const render = async (): Promise => act(() => mountedRoot?.render( + + + , )); - await render(true); - assert.deepEqual(calls, [], 'mounting at a partial tail is not a request'); - wheel(scroller, -100); - scroller.scrollTop = 900; - scroller.dispatchEvent(new window.Event('scroll')); - wheel(scroller, 100); - scroller.scrollTop = 1000; - scroller.dispatchEvent(new window.Event('scroll')); - assert.deepEqual(calls, [ - { direction: 'up', anchor: 'turn-1' }, - { direction: 'down', anchor: 'turn-2' }, - { direction: 'down', anchor: 'turn-2' }, // Input and its resulting scroll. - ], 'overlapping edge bands must not reverse the requested direction'); - scroller.scrollTop = 1800; - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(authority.getSnapshot().pinned, false, 'a partial tail must not follow a page fill'); - calls.length = 0; - wheel(scroller, 100); - assert.deepEqual(calls, [{ direction: 'down', anchor: 'turn-3' }]); + await render(); + // Opened at the tail: nothing lies below, so the newer edge is inside the + // band even though the reader never moved. + assert.equal(transcript.scrollTop, 1_800); + assert.deepEqual(calls, ['down']); - const nested = document.createElement('div'); - Object.defineProperties(nested, { - clientHeight: { value: 100 }, scrollHeight: { value: 500 }, scrollTop: { value: 100 }, + calls.length = 0; + transcript.readerScrollTo(900); + // 900px above and 900px below, both inside two screens. The downward fetch + // is already in flight, so only the older edge is asked. + assert.deepEqual(calls, ['up']); + transcript.readerScrollTo(800); + assert.deepEqual(calls, ['up'], 'a direction with a request in flight is not asked again'); + + // Both pages land, and the edges they established close the transcript. + history = { older: false, newer: false }; + await render(); + await act(async () => { + for (const resolve of resolvers.splice(0)) resolve(); }); - scroller.append(nested); calls.length = 0; - wheel(nested, 100); - assert.deepEqual(calls, [], 'scrolling a nested tool output must not page the transcript'); + transcript.readerScrollTo(200); + assert.deepEqual(calls, [], 'no request beyond an authoritative history edge'); + + // Only the tail is open now: the reader moving up still asks for it, + // because what decides is the band, not the direction of the gesture. + history = { older: false, newer: true }; + await render(); + transcript.readerScrollTo(1_000); + assert.deepEqual(calls, ['down']); +}); - scroller.scrollTop = 0; - scroller.dispatchEvent(new window.Event('scroll')); - calls.length = 0; - wheel(scroller, -100); - assert.deepEqual(calls, [{ direction: 'up', anchor: 'turn-0' }]); - assert.equal(scroller.scrollTop, 1, 'keep native anchoring enabled at the start'); - await render(false); - calls.length = 0; - wheel(scroller, -100); - scroller.scrollTop = 1800; - scroller.dispatchEvent(new window.Event('scroll')); - wheel(scroller, 100); - assert.deepEqual(calls, [], 'do not request beyond authoritative history edges'); +test('an older request at offset zero restores the browser anchoring the reader depends on', async () => { + const { document, window } = parseHTML( + '
', + ); + installScrollTestEnvironment(document, window, { queueFrames: false }); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 8, + }); + + let requests = 0; + function Harness() { + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-top', + messages: [{ id: 'message-1' }] as StoredMessage[], + behavior: 'auto', + hasOlderHistory: true, + onLoadEarlierHistory: () => { + requests += 1; + return new Promise(() => undefined); + }, + }); + return null; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render( + , + )); + assert.equal(requests, 0, 'the tail of a deep transcript is nowhere near the older edge'); + + transcript.readerScrollTo(0); + assert.equal(requests, 1); + assert.equal(transcript.scrollTop, 1, 'keep native anchoring enabled at the start'); +}); + +test('a transcript change re-reads the band while the reader stays at the tail', async () => { + const { document, window } = parseHTML( + '
', + ); + installScrollTestEnvironment(document, window, { queueFrames: false }); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 8, + }); + + let requests = 0; + function Harness({ messages }: { messages: readonly StoredMessage[] }) { + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-tail', + messages, + behavior: 'auto', + hasOlderHistory: true, + onLoadEarlierHistory: () => { + requests += 1; + return new Promise(() => undefined); + }, + }); + return null; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + const render = async (messages: readonly StoredMessage[]): Promise => + act(() => mountedRoot?.render( + , + )); + + await render([{ id: 'message-1' }] as StoredMessage[]); + assert.equal(requests, 0); + assert.equal(transcript.scrollTop, 4_200); + + // A trim leaves the reader pinned at a tail with barely a screen above it. + // Nobody scrolled, so only the transcript itself can report the band. + transcript.setTurnCount(2); + transcript.scroller.scrollTop = transcript.scroller.scrollHeight; + await render([{ id: 'message-2' }] as StoredMessage[]); + assert.equal(requests, 1); +}); + +test('the retained window is the band around the reader, and and an unmounted bookmark cannot freeze it', async () => { + const { document, window } = parseHTML( + '
', + ); + installScrollTestEnvironment(document, window, { queueFrames: false }); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 20, + }); + + const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; + function Harness({ + messages, + unavailable, + }: { messages: readonly StoredMessage[]; unavailable: boolean }) { + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-window', + messages, + restoreTarget: { turnId: 'turn-never-mounted', unavailable }, + behavior: 'auto', + onRetainWindow: (value) => { retained.push(value); }, + }); + return null; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + const render = async ( + messages: readonly StoredMessage[], + unavailable: boolean, + ): Promise => act(() => mountedRoot?.render( + + + , + )); + + // A bookmark whose Turn is not mounted cannot be trimmed away, so waiting + // for it would only let the window grow without a bound. The window is four + // screens of Turns around a scrollport 20 screens deep. + await render([{ id: 'message-1' }] as StoredMessage[], false); + assert.equal(transcript.scrollTop, 11_400); + assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-14', lastTurnId: 'turn-19' }); + + retained.length = 0; + await render([{ id: 'message-2' }] as StoredMessage[], true); + assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-14', lastTurnId: 'turn-19' }); + + retained.length = 0; + transcript.readerScrollTo(6_000); + assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-5', lastTurnId: 'turn-15' }); + + // Six screens is the threshold: with less than that beyond the scrollport in + // both directions there is nothing worth dropping. + transcript.setTurnCount(8); + transcript.readerScrollTo(2_000); + retained.length = 0; + transcript.readerScrollTo(2_100); + assert.deepEqual(retained, []); }); test('a session switch restores a Turn anchor after async fill and preserves tail intent', async () => { @@ -233,17 +428,7 @@ test('a session switch restores a Turn anchor after async fill and preserves tai }, }, }); - scroller.getBoundingClientRect = () => ({ - bottom: 600, - height: 600, - left: 0, - right: 800, - top: 0, - width: 800, - x: 0, - y: 0, - toJSON: () => undefined, - }); + scroller.getBoundingClientRect = () => boxOf(0, 600); const { frames, resizeCallbacks } = installScrollTestEnvironment(document, window); @@ -256,17 +441,8 @@ test('a session switch restores a Turn anchor after async fill and preserves tai for (const turn of turns) { const element = document.createElement('article'); element.dataset.turnId = turn.id; - element.getBoundingClientRect = () => ({ - bottom: turn.start + turn.height - scrollTop, - height: turn.height, - left: 0, - right: 800, - top: turn.start - scrollTop, - width: 800, - x: 0, - y: turn.start - scrollTop, - toJSON: () => undefined, - }); + element.getBoundingClientRect = () => + boxOf(turn.start - scrollTop, turn.start + turn.height - scrollTop); element.scrollIntoView = (options?: boolean | ScrollIntoViewOptions) => { const block = typeof options === 'object' ? options.block : undefined; scroller.scrollTop = block === 'center' @@ -297,8 +473,6 @@ test('a session switch restores a Turn anchor after async fill and preserves tai const handledTargets: number[] = []; const viewportNavigation = createTranscriptViewportNavigation(); const unavailableRestores = new Map(); - const historyRequests: Array<{ direction: 'up' | 'down'; anchor?: string }> = []; - let historyPaging = false; let authority: TranscriptScrollAuthority | undefined; let messageRevision = 0; let target: { turnId: string; nonce: number } | undefined; @@ -324,10 +498,6 @@ test('a session switch restores a Turn anchor after async fill and preserves tai else anchors.delete(sessionId); }, behavior: 'auto', - hasOlderHistory: historyPaging, - hasNewerHistory: historyPaging, - onLoadEarlierHistory: (anchor) => { historyRequests.push({ direction: 'up', anchor }); }, - onLoadLaterHistory: (anchor) => { historyRequests.push({ direction: 'down', anchor }); }, }); return null; } @@ -472,146 +642,56 @@ test('a session switch restores a Turn anchor after async fill and preserves tai assert.equal(authority?.getSnapshot().pinned, false); assert.equal(authority?.getSnapshot().awayFromTail, false); assert.equal(anchors.get('session-a'), 'turn-a-latest', 'range geometry does not report a new reading intent'); - - // Either adjacent-page gesture supersedes an activation's unfinished - // bookmark. A late fill must not move the reader back to that old target. - historyPaging = true; - for (const direction of ['up', 'down'] as const) { - const sessionId = `session-page-${direction}`; - const bookmark = `bookmark-${direction}`; - anchors.set(sessionId, bookmark); - collapseTranscript(); - installTranscript(3_000, [{ id: 'resident', start: 0, height: 3_000 }]); - await renderSession(sessionId); - assert.equal(authority?.getSnapshot().pinned, false); - scroller.scrollTop = direction === 'up' ? 0 : 2_400; - const wheel = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(wheel, 'deltaY', { value: direction === 'up' ? -100 : 100 }); - scroller.dispatchEvent(wheel); - assert.deepEqual(historyRequests.at(-1), { direction, anchor: 'resident' }); - const readerTop: number = scroller.scrollTop; - - installTranscript(3_000, [ - { id: 'resident-before', start: 0, height: 800 }, - { id: bookmark, start: 800, height: 600 }, - { id: 'resident-after', start: 1_400, height: 1_600 }, - ]); - await renderSession(sessionId); - await flushFrames(); - assert.equal(scroller.scrollTop, readerTop, `${direction} paging consumes the pending restore`); - assert.equal(authority?.getSnapshot().pinned, false); - } }); -/** - * A bookmark left on a Turn the last range evicted makes the restore effect - * load around it over the range paging just published, and the transcript - * stops advancing — the stall the E2E paging guard sees as a timeout. - */ -test('a wheel at the top edge reports its anchor before it loads earlier history', async () => { +test('a target lands on the render that mounts its Turn, whatever moved the range', async () => { const { document, window } = parseHTML( '
', ); - const mount = document.querySelector('#mount'); - const scroller = document.querySelector('#scroller'); - assert.ok(mount); - assert.ok(scroller); - - let scrollHeight = 1_600; - let scrollTop = 0; - Object.defineProperties(scroller, { - clientHeight: { value: 600 }, - scrollHeight: { get: () => scrollHeight }, - scrollTop: { - get: () => scrollTop, - // No scroll event follows a write: that is the edge under test. - set: (value: number) => { - scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); - }, - }, + const { frames } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 3, }); - scroller.getBoundingClientRect = () => ({ - bottom: 600, - height: 600, - left: 0, - right: 800, - top: 0, - width: 800, - x: 0, - y: 0, - toJSON: () => undefined, - }); - - installScrollTestEnvironment(document, window, { queueFrames: false }); - const installTurns = (ids: readonly string[]): void => { - scrollHeight = ids.length * 800; - scroller.replaceChildren(); - ids.forEach((id, index) => { - const element = document.createElement('article'); - element.dataset.turnId = id; - const start = index * 800; - element.getBoundingClientRect = () => ({ - bottom: start + 800 - scrollTop, - height: 800, - left: 0, - right: 800, - width: 800, - x: 0, - top: start - scrollTop, - y: start - scrollTop, - toJSON: () => undefined, - }); - element.scrollIntoView = () => { - scroller.scrollTop = start; - }; - scroller.append(element); - }); - }; - let anchor: string | undefined; - const loads: Array<{ anchorTurnId?: string; anchorWhenAsked?: string }> = []; + // The Renderer owns the window now: a jump to an unloaded Turn changes the + // resident range without touching the message list the shell passes down. + const messages = [{ id: 'message-1' }] as StoredMessage[]; + const handledTargets: number[] = []; + let highlighted: string | null = null; function Harness() { - const scrollRef = useRef(scroller); - useChatScroll({ + const scrollRef = useRef(transcript.scroller); + const result = useChatScroll({ scrollRef, - sessionId: 'session-paging', - messages: [{ id: 'message-1' }] as StoredMessage[], - // A remembered position leaves the hook unpinned, as paging back does. - restoreTarget: { turnId: 'turn-0' }, - onReadingAnchorChange: (turnId) => { - anchor = turnId; - }, + sessionId: 'session-jump', + messages, + target: { turnId: 'turn-5', nonce: 7 }, + onTargetHandled: (nonce) => handledTargets.push(nonce), behavior: 'auto', - hasOlderHistory: true, - onLoadEarlierHistory: (anchorTurnId) => { - loads.push({ anchorTurnId, anchorWhenAsked: anchor }); - }, }); + highlighted = result.highlightedTurnId; return null; } - - installTurns(['turn-0', 'turn-1']); - mountedRoot = createRoot(mount); - await act(() => mountedRoot?.render( - - - , + const render = async (): Promise => act(() => mountedRoot?.render( + , )); - // Settle the authority on "unpinned, away from the tail", where the wheel's - // own release publishes nothing and so refreshes no anchor. - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(anchor, 'turn-0', 'the restored position is the reading anchor'); - - installTurns(['turn-earlier', 'turn-0']); - scroller.scrollTop = 0; + const flushFrames = async (): Promise => { + await act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); + }; - const wheel = new window.Event('wheel'); - Object.assign(wheel, { deltaY: -120, composedPath: () => [scroller] }); - scroller.dispatchEvent(wheel); + mountedRoot = createRoot(document.querySelector('#mount')!); + await render(); + await flushFrames(); + assert.equal(highlighted, null, 'a Turn that is not mounted cannot be revealed yet'); + assert.deepEqual(handledTargets, []); - assert.deepEqual( - loads.at(-1), - { anchorTurnId: 'turn-earlier', anchorWhenAsked: 'turn-earlier' }, - 'the wheel bookmarks the Turn it anchors the load to', - ); + transcript.setTurnCount(8); + await render(); + await flushFrames(); + assert.equal(highlighted, 'turn-5'); + assert.deepEqual(handledTargets, [7]); + assert.equal(transcript.scrollTop, 3_000, 'the reveal puts the Turn at the top edge'); }); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index a764d921b3..6f27710c03 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -317,8 +317,9 @@ export function ChatView(props: { hasOlderHistory?: boolean; hasNewerHistory?: boolean; historyLoadPending?: TranscriptHistoryLoadDirection; - onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; - onLoadLaterHistory?(anchorTurnId?: string): Promise | void; + onLoadEarlierHistory?(): Promise | void; + onLoadLaterHistory?(): Promise | void; + onRetainWindow?(window: { firstTurnId: string; lastTurnId: string }): void; transcriptTurnIndex?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>; /** Optional identity decorations shared with a host's work navigation. */ promptRailDecorations?: ReadonlyMap>; @@ -639,6 +640,7 @@ export function ChatView(props: { onLoadEarlierHistory: props.onLoadEarlierHistory, hasNewerHistory: props.hasNewerHistory, onLoadLaterHistory: props.onLoadLaterHistory, + onRetainWindow: props.onRetainWindow, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, @@ -839,12 +841,12 @@ export function ChatView(props: { ? { description: copy.transcriptGap.olderDescription, actionLabel: copy.transcriptGap.olderAction, - activate: () => props.onLoadEarlierHistory?.(turns[0]?.turnId), + activate: () => props.onLoadEarlierHistory?.(), } : { description: copy.transcriptGap.newerDescription, actionLabel: copy.transcriptGap.newerAction, - activate: () => props.onLoadLaterHistory?.(turns.at(-1)?.turnId), + activate: () => props.onLoadLaterHistory?.(), }; return ( void): number; - cancel(handle: number): void; -} - -const browserFrameScheduler: PromptRailFrameScheduler = { - request: (callback) => requestAnimationFrame(callback), - cancel: (handle) => cancelAnimationFrame(handle), -}; - -/** - * Hold a jump's destination while the transcript grows under it. - * - * Content can still resolve while a jump is moving, changing geometry under its - * destination. Releasing the tail (`onNavigateStart`) does not answer that — - * only re-aiming does, through each geometry change, and once more if a still - * frame finds the target off the top edge. A frame where nothing moved and the - * target is where the click asked costs one `getBoundingClientRect` and nothing - * else. - * - * `onNavigateStart` is re-asserted on every frame of the hold rather than once - * at the click. It is idempotent, and a jump that starts from the tail would - * otherwise be re-pinned by the first scroll the mounting window produces. - * - * The hold ends after the mounted destination is still for a few frames, or - * the moment the reader takes the transcript back. - */ -export function holdJumpDestination(input: { - root: Element; - readTargetId: () => string | null; - /** The tail release, re-asserted for the life of the hold. */ - releaseAutoFollow?: (() => void) | undefined; - onSettled: () => void; - scheduler?: PromptRailFrameScheduler; -}): () => void { - const { root, readTargetId, releaseAutoFollow, onSettled } = input; - const scheduler = input.scheduler ?? browserFrameScheduler; - let handle = 0; - let done = false; - let lastHeight = root.scrollHeight; - let lastTop = root.scrollTop; - let quietFrames = 0; - let framesRun = 0; - - const stop = (): void => { - if (done) return; - done = true; - scheduler.cancel(handle); - for (const type of READER_SCROLL_EVENTS) root.removeEventListener(type, stop); - onSettled(); - }; - - const reaim = (): { found: boolean; corrected: boolean } => { - const turnId = readTargetId(); - if (turnId === null) return { found: false, corrected: false }; - const target = root.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); - if (!target) return { found: false, corrected: false }; - const offset = target.getBoundingClientRect().top - root.getBoundingClientRect().top; - if (Math.abs(offset) <= JUMP_LANDED_TOLERANCE_PX) { - return { found: true, corrected: false }; - } - const before = root.scrollTop; - // `auto`: this is a correction, not a second journey. - (target as HTMLElement).scrollIntoView({ behavior: 'auto', block: 'start' }); - lastTop = root.scrollTop; - // A correction that moves nothing means the target is as close to the top - // as this scroller can put it — the last turn of a transcript cannot reach - // it at all. Report it as landed, or the hold would keep trying until its - // frame budget ran out. - return { found: true, corrected: root.scrollTop !== before }; - }; - - const hold = (): void => { - if (done) return; - handle = scheduler.request(hold); - framesRun += 1; - releaseAutoFollow?.(); - const grew = root.scrollHeight !== lastHeight; - const moved = root.scrollTop !== lastTop; - lastHeight = root.scrollHeight; - lastTop = root.scrollTop; - // Growth can move the destination while content resolves; re-aim through it. - // A still frame that is nonetheless off-target is the other failure: a - // scroll that was cancelled part-way and will never resume on its own, - // which is what happens when the mount's compensation lands on top of one. - const target = grew || !moved - ? reaim() - : { found: false, corrected: false }; - // Quiet means nothing moved at all — not the content, not the position. - // Height alone was not enough: with the transcript already mounted there - // is nothing to re-aim through, and the hold released three frames in, - // handing the highlight and the tail release back while the jump’s - // own scroll was still in flight. - if ( - !grew && - !moved && - !target.corrected && - target.found - ) quietFrames += 1; - else quietFrames = 0; - if (quietFrames >= JUMP_SETTLE_QUIET_FRAMES || framesRun >= JUMP_HOLD_FRAME_BUDGET) stop(); - }; - - for (const type of READER_SCROLL_EVENTS) { - root.addEventListener(type, stop, { passive: true }); - } - handle = scheduler.request(hold); - return stop; -} - type PromptRailResizeObserverFactory = ( onResize: () => void, ) => PromptRailResizeObserver; @@ -259,161 +126,51 @@ export interface PromptAnchorRailProps { onNavigateStart?: (() => void) | undefined; } -export function selectPromptRailActiveTurn(input: { - atEnd: boolean; - mountedTurnIds: Iterable; - readingBandTurnIds: Iterable; - scrollportTurnIds: Iterable; - turnIndexById: ReadonlyMap; -}): string | null { - const readingBandTurnIds = [...input.readingBandTurnIds]; - const candidates = input.atEnd - ? input.mountedTurnIds - : readingBandTurnIds.length > 0 - ? readingBandTurnIds - : input.scrollportTurnIds; - let selected: string | null = null; - let selectedIndex = input.atEnd ? -1 : Number.POSITIVE_INFINITY; - for (const turnId of candidates) { - const index = input.turnIndexById.get(turnId); - if (index === undefined) continue; - if ( - (input.atEnd && index > selectedIndex) - || (!input.atEnd && index < selectedIndex) - ) { - selected = turnId; - selectedIndex = index; - } - } - return selected; -} - -export function selectPromptRailTickForMountedTurn(input: { - activeTurnId: string; - mountedTurnIds: readonly string[]; - railTurns: readonly PromptAnchorRailTurn[]; +/** + * The tick for a reading position the rail may not have sampled. Past its cap + * the rail shows one tick per few Turns, so it projects the reader's Turn onto + * the sampled positions the same way the sampling picked them. + */ +export function selectPromptRailTick(input: { + readingTurnId: string | undefined; + orderedTurnIds: readonly string[]; + railTurnIds: readonly string[]; previousRailTurnId: string | null; - atEnd: boolean; }): string | null { - const previousRailTurnId = input.railTurns.some( - (turn) => turn.turnId === input.previousRailTurnId, - ) ? input.previousRailTurnId : null; - const fallbackRailTurnId = input.atEnd - ? input.railTurns.at(-1)?.turnId ?? null - : previousRailTurnId ?? input.railTurns[0]?.turnId ?? null; - const direct = input.railTurns.find((turn) => turn.turnId === input.activeTurnId); - if (direct) return direct.turnId; - const activeIndex = input.mountedTurnIds.indexOf(input.activeTurnId); - if (activeIndex === -1) return fallbackRailTurnId; - const mountedRailTurns = input.mountedTurnIds.flatMap((turnId, mountedIndex) => { - const railIndex = input.railTurns.findIndex((turn) => turn.turnId === turnId); - return railIndex === -1 ? [] : [{ mountedIndex, railIndex }]; - }); - const nearestMountedRailTurn = [...mountedRailTurns] - .sort((left, right) => - Math.abs(left.mountedIndex - activeIndex) - Math.abs(right.mountedIndex - activeIndex) - || left.mountedIndex - right.mountedIndex, - )[0]; - const nearestMountedRailTurnId = nearestMountedRailTurn - ? input.railTurns[nearestMountedRailTurn.railIndex]?.turnId ?? null - : null; - const sequenceAnchors = mountedRailTurns - .flatMap(({ mountedIndex, railIndex }) => { - const sequence = input.railTurns[railIndex]?.sequence; - return sequence === undefined ? [] : [{ mountedIndex, railIndex, sequence }]; - }) - .sort((left, right) => - Math.abs(left.mountedIndex - activeIndex) - Math.abs(right.mountedIndex - activeIndex) - || left.mountedIndex - right.mountedIndex, - ) - .slice(0, 2) - .sort((left, right) => left.mountedIndex - right.mountedIndex); - const [firstAnchor, secondAnchor] = sequenceAnchors; - if (!firstAnchor || !secondAnchor) { - return firstAnchor - ? input.railTurns[firstAnchor.railIndex]?.turnId ?? null - : nearestMountedRailTurnId ?? fallbackRailTurnId; - } - const activeSequence = firstAnchor.sequence - + (secondAnchor.sequence - firstAnchor.sequence) - * (activeIndex - firstAnchor.mountedIndex) - / (secondAnchor.mountedIndex - firstAnchor.mountedIndex); - const firstSequence = input.railTurns[0]?.sequence; - const lastSequence = input.railTurns[input.railTurns.length - 1]?.sequence; - const projectedRailIndex = firstSequence !== undefined - && lastSequence !== undefined - && lastSequence > firstSequence - ? Math.round( - (activeSequence - firstSequence) - * (input.railTurns.length - 1) - / (lastSequence - firstSequence), - ) - : firstAnchor.railIndex; - let selected: PromptAnchorRailTurn | null = null; - let selectedIndex = -1; - let selectedDistance = Number.POSITIVE_INFINITY; - const candidateRange = activeIndex < firstAnchor.mountedIndex - ? [Math.max(0, firstAnchor.railIndex - 1), firstAnchor.railIndex] - : activeIndex > secondAnchor.mountedIndex - ? [ - secondAnchor.railIndex, - Math.min(input.railTurns.length - 1, secondAnchor.railIndex + 1), - ] - : [firstAnchor.railIndex, secondAnchor.railIndex]; - for (let index = 0; input.railTurns.length > index; index += 1) { - if (index < candidateRange[0]! || index > candidateRange[1]!) continue; - const turn = input.railTurns[index]!; - if (turn.sequence === undefined) continue; - const distance = Math.abs(turn.sequence - activeSequence); - if ( - distance < selectedDistance - || ( - distance === selectedDistance - && Math.abs(index - projectedRailIndex) < Math.abs(selectedIndex - projectedRailIndex) - ) - ) { - selected = turn; - selectedIndex = index; - selectedDistance = distance; + const { readingTurnId, orderedTurnIds, railTurnIds } = input; + if (readingTurnId !== undefined) { + if (railTurnIds.includes(readingTurnId)) return readingTurnId; + const readingIndex = orderedTurnIds.indexOf(readingTurnId); + if (readingIndex !== -1 && orderedTurnIds.length > 1 && railTurnIds.length > 1) { + return railTurnIds[Math.round( + readingIndex * (railTurnIds.length - 1) / (orderedTurnIds.length - 1), + )] ?? null; } } - return selected?.turnId ?? fallbackRailTurnId; + // An unknown reading position — a Turn the rail has no landmark for, or none + // reported yet — leaves the current tick alone rather than jumping it home. + return input.previousRailTurnId !== null && railTurnIds.includes(input.previousRailTurnId) + ? input.previousRailTurnId + : null; } /** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart, onHighlightTurn }: PromptAnchorRailProps): React.ReactElement | null { const copy = getConversationCopy(useUiLocale()).sessions; - const [activeSelection, setActiveSelection] = useState<{ - turnId: string; - atEnd: boolean; - } | null>(null); - const activeTurnId = activeSelection?.turnId ?? null; - const [mountedTurnIds, setMountedTurnIds] = useState([]); + const authority = useTranscriptScrollAuthority(); + const snapshot = useSyncExternalStore( + authority.subscribe, + authority.getSnapshot, + authority.getSnapshot, + ); const [safeArea, setSafeArea] = useState<{ scrollport: number; dock: number } | null>(null); const railRef = useRef(null); const previousActiveRailTurnIdRef = useRef(null); const [hoveredIndex, setHoveredIndex] = useState(null); const activeVisibilityFrame = useRef(0); - const markActiveTurn = useCallback((turnId: string, atEnd = false) => { - setActiveSelection((current) => - current?.turnId === turnId && current.atEnd === atEnd ? current : { turnId, atEnd }, - ); - }, []); - // Identified by a sequence number rather than a boolean so a second click - // during a jump starts its own claim instead of inheriting what is left of - // the first one's — which would leave the earlier jump's lifetime governing - // the later jump's target. - const [jump, setJump] = useState<{ sequence: number; turnId: string } | null>(null); - const jumpSequenceRef = useRef(0); - // The turn a click aimed at, held until that click's scroll settles. A ref, - // not state: the observer effect reads it on every scroll frame and must not - // be torn down and rebuilt over the whole transcript when it changes. - const jumpTargetRef = useRef(null); - const onNavigateStartRef = useRef(onNavigateStart); - onNavigateStartRef.current = onNavigateStart; - // Prompt/reply text changes while an answer streams, but the scroll spy only + // Prompt/reply text changes while an answer streams, but the tick layout only // depends on Turn identity and order. Keep that structural value stable so a - // text delta does not tear down and rebuild every transcript observer. + // text delta does not rebuild the sampling. const orderedTurnIdsRef = useRef([]); const nextOrderedTurnIds = turns.map((turn) => turn.turnId); if ( @@ -436,27 +193,12 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe [orderedTurnIds, railTurnIndexes], ); const railTurns = railTurnIndexes.map((turnIndex) => turns[turnIndex]!); - const mappedActiveRailTurnId = (() => { - if (activeTurnId === null) return null; - if (railTurnIds.includes(activeTurnId)) return activeTurnId; - const orderedActiveIndex = orderedTurnIds.indexOf(activeTurnId); - if (orderedActiveIndex !== -1 && orderedTurnIds.length > railTurnIds.length) { - return railTurnIds[Math.round( - orderedActiveIndex * (railTurnIds.length - 1) / (orderedTurnIds.length - 1), - )] ?? null; - } - return selectPromptRailTickForMountedTurn({ - activeTurnId, - mountedTurnIds, - railTurns, - previousRailTurnId: previousActiveRailTurnIdRef.current, - atEnd: activeSelection?.atEnd ?? false, - }); - })(); - const activeRailTurnId = mappedActiveRailTurnId - ?? (railTurnIds.includes(previousActiveRailTurnIdRef.current ?? '') - ? previousActiveRailTurnIdRef.current - : null); + const activeRailTurnId = selectPromptRailTick({ + readingTurnId: snapshot.readingTurnId, + orderedTurnIds, + railTurnIds, + previousRailTurnId: previousActiveRailTurnIdRef.current, + }); useEffect(() => { if (activeRailTurnId !== null) previousActiveRailTurnIdRef.current = activeRailTurnId; }, [activeRailTurnId]); @@ -481,161 +223,6 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe }; }, [activeRailTurnId]); - useEffect(() => { - const root = scrollRef.current; - const messageList = root?.querySelector('.maka-chat-message-list'); - // Astryx ChatMessageList renders one inner flex column as its first child; - // that column is the direct parent of Maka's keyed transcript Turn wrappers. - const mountedTurnList = messageList?.firstElementChild; - if (!root || !mountedTurnList || orderedTurnIds.length === 0) return; - - const idByElement = new Map(); - let mountedTurnIndexById = new Map(); - const readingBandTurnIds = new Set(); - const observeElement = (element: Element): void => { - const turnId = element.getAttribute('data-transcript-turn-id'); - if (!turnId || idByElement.has(element)) return; - idByElement.set(element, turnId); - observer.observe(element); - }; - const unobserveElement = (element: Element): void => { - const turnId = idByElement.get(element); - if (!turnId) return; - idByElement.delete(element); - readingBandTurnIds.delete(turnId); - observer.unobserve(element); - }; - const visitTurnElements = (node: Node, visit: (element: Element) => void): void => { - if (!(node instanceof Element)) return; - if (node.hasAttribute('data-transcript-turn-id')) visit(node); - for (const element of node.querySelectorAll('[data-transcript-turn-id]')) visit(element); - }; - const refreshMountedTurnOrder = (): void => { - const nextMountedTurnIds = [...mountedTurnList.querySelectorAll( - '[data-transcript-turn-id]', - )].flatMap((element) => { - const turnId = element.getAttribute('data-transcript-turn-id'); - return turnId ? [turnId] : []; - }); - mountedTurnIndexById = new Map( - nextMountedTurnIds.map((turnId, index) => [turnId, index]), - ); - setMountedTurnIds((current) => - current.length === nextMountedTurnIds.length - && nextMountedTurnIds.every((turnId, index) => current[index] === turnId) - ? current - : nextMountedTurnIds, - ); - }; - const turnIdsIntersecting = (top: number, bottom: number): string[] => { - const turnIds: string[] = []; - for (const [element, turnId] of idByElement) { - const bounds = element.getBoundingClientRect(); - if (bounds.bottom > top && bounds.top < bottom) turnIds.push(turnId); - } - return turnIds; - }; - const seedReadingBandFromGeometry = (): void => { - const rootBounds = root.getBoundingClientRect(); - readingBandTurnIds.clear(); - for (const turnId of turnIdsIntersecting( - rootBounds.top, - rootBounds.top + rootBounds.height * (READING_BAND_TOP_PERCENT / 100), - )) { - readingBandTurnIds.add(turnId); - } - }; - const resolveActive = (): void => { - // A jump owns the highlight until its scroll settles. Without this the - // observer walks the highlight through every prompt the scroll passes, - // which is the travelling the click was meant to skip. - if (jumpTargetRef.current !== null) return; - const atEnd = - root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX; - const rootBounds = !atEnd && readingBandTurnIds.size === 0 - ? root.getBoundingClientRect() - : null; - const active = selectPromptRailActiveTurn({ - atEnd, - mountedTurnIds: idByElement.values(), - readingBandTurnIds, - scrollportTurnIds: rootBounds !== null - ? turnIdsIntersecting(rootBounds.top, rootBounds.bottom) - : [], - turnIndexById: mountedTurnIndexById, - }); - if (active !== null) markActiveTurn(active, atEnd); - }; - - const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - const turnId = idByElement.get(entry.target); - if (!turnId) continue; - if (entry.intersectionRect.height > 0) readingBandTurnIds.add(turnId); - else readingBandTurnIds.delete(turnId); - } - resolveActive(); - }, { - root, - rootMargin: `0px 0px -${100 - READING_BAND_TOP_PERCENT}% 0px`, - // The positive threshold delivers a callback when an overlap becomes - // a zero-area boundary touch, which the strict geometry rule excludes. - threshold: [0, POSITIVE_INTERSECTION_RATIO], - }); - for (const element of mountedTurnList.querySelectorAll('[data-transcript-turn-id]')) { - observeElement(element); - } - refreshMountedTurnOrder(); - seedReadingBandFromGeometry(); - resolveActive(); - - let membershipFrame = 0; - let membershipFramesLeft = 0; - const settleMembershipGeometry = (): void => { - membershipFrame = requestAnimationFrame(() => { - membershipFrame = 0; - seedReadingBandFromGeometry(); - resolveActive(); - membershipFramesLeft -= 1; - if (membershipFramesLeft > 0) settleMembershipGeometry(); - }); - }; - const mutationObserver = new MutationObserver((records) => { - for (const record of records) { - for (const node of record.removedNodes) visitTurnElements(node, unobserveElement); - for (const node of record.addedNodes) visitTurnElements(node, observeElement); - } - refreshMountedTurnOrder(); - // Browser scroll anchoring and the paged transcript projection can land - // across several frames after the child-list mutation. Follow that short - // settle window, or a prepended page can leave its previous boundary - // Turn current after the replacement is being read. - membershipFramesLeft = 6; - if (membershipFrame === 0) { - settleMembershipGeometry(); - } - }); - mutationObserver.observe(mountedTurnList, { childList: true }); - - let frame = 0; - const onScroll = (): void => { - if (frame !== 0) return; - frame = requestAnimationFrame(() => { - frame = 0; - resolveActive(); - }); - }; - root.addEventListener('scroll', onScroll, { passive: true }); - - return () => { - observer.disconnect(); - mutationObserver.disconnect(); - root.removeEventListener('scroll', onScroll); - if (membershipFrame !== 0) cancelAnimationFrame(membershipFrame); - if (frame !== 0) cancelAnimationFrame(frame); - }; - }, [markActiveTurn, orderedTurnIds, scrollRef]); - useEffect(() => { const root = scrollRef.current; if (!root) return; @@ -676,43 +263,13 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe return observeActivePromptRailVisibility(rail); }, [orderedTurnIds]); - // A click owns the highlight until the destination settles, so the scroll it - // started cannot walk the active tick through every prompt on the way. Keyed - // on the jump sequence so a second click starts its own claim. - // - // The hold re-aims through content geometry changes and reports back - // when the mounted destination is still, or when the reader takes it back. - useEffect(() => { - if (!jump) return; - const root = scrollRef.current; - if (!root) return; - return holdJumpDestination({ - root, - readTargetId: () => jumpTargetRef.current, - releaseAutoFollow: onNavigateStartRef.current, - onSettled: () => { - // Only the latest jump releases the highlight: a later click has - // already claimed it, and its own hold owns it now. Decided against - // the ref rather than inside the state updater, which React may run - // twice. - if (jumpSequenceRef.current !== jump.sequence) return; - jumpTargetRef.current = null; - setJump((current) => (current?.sequence === jump.sequence ? null : current)); - }, - }); - }, [jump, scrollRef]); - function jumpTo(turn: PromptAnchorRailTurn): void { - const turnId = turn.turnId; const root = scrollRef.current; - const el = root?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); + const el = root?.querySelector(`[data-turn-id="${CSS.escape(turn.turnId)}"]`); // Before the scroll, not after: the tail has to be released while the // transcript is still where the reader left it, or the release lands after // the next growth has already written the view back to the bottom. onNavigateStart?.(); - // Claimed before the scroll starts: a same-frame `scroll` event would - // otherwise reach the observer while the highlight is still unowned. - jumpTargetRef.current = turnId; if (el && 'scrollIntoView' in el) { // Instant, whatever the app's scroll-motion policy says. A jump is a // teleport the reader asked for, not a journey — and an animated one @@ -724,9 +281,6 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe } else if (!el) { onNavigateFallback?.(turn); } - jumpSequenceRef.current += 1; - setJump({ sequence: jumpSequenceRef.current, turnId }); - markActiveTurn(turnId); } // A rail is only useful once there are a few prompts to jump between. diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 097ad392a2..8bbe4e9bcb 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -51,6 +51,12 @@ export interface TranscriptScrollSnapshot { readonly pinned: boolean; /** Far enough up that the return-to-tail affordance earns its place. */ readonly awayFromTail: boolean; + /** + * The Turn the reader is on: the first whose box crosses the scrollport's + * top edge. One reading line for every consumer — the bookmark that survives + * a session switch and the rail's current tick have to name the same Turn. + */ + readonly readingTurnId: string | undefined; } export interface TranscriptScrollAuthority { @@ -70,6 +76,12 @@ export interface TranscriptScrollAuthority { * consumers do not interpret raw wheel or scroll events themselves. */ subscribeToReaderScroll(listener: (direction: 'up' | 'down', phase: 'input' | 'scroll') => void): () => void; + /** + * The reading position, measured now and published like any other move. For + * a caller that has just moved the viewport or the content itself and cannot + * wait for the scroll or resize that will report it. + */ + measureReadingTurn(): string | undefined; subscribe(listener: () => void): () => void; getSnapshot(): TranscriptScrollSnapshot; } @@ -95,15 +107,27 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // Geometry belongs to a known input operation, never the other way around. // scrollend also covers smooth keyboard scrolling and touchpad inertia. let gesture: { top: number; direction?: 'up' | 'down' } | undefined; - let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail }; + let readingTurnId: string | undefined; + let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail, readingTurnId }; const listeners = new Set<() => void>(); const readerListeners = new Set<(direction: 'up' | 'down', phase: 'input' | 'scroll') => void>(); const distanceToTail = (): number => root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; + const readTurn = (): string | undefined => { + if (!root) return undefined; + const top = root.getBoundingClientRect().top; + for (const turn of root.querySelectorAll('[data-turn-id]')) { + if (turn.getBoundingClientRect().bottom > top) { + return turn.getAttribute('data-turn-id') ?? undefined; + } + } + return undefined; + }; const publish = (): void => { if (root) root.style.overflowAnchor = pinned ? 'none' : 'auto'; - if (snapshot.pinned === pinned && snapshot.awayFromTail === awayFromTail) return; - snapshot = { pinned, awayFromTail }; + if (snapshot.pinned === pinned && snapshot.awayFromTail === awayFromTail + && snapshot.readingTurnId === readingTurnId) return; + snapshot = { pinned, awayFromTail, readingTurnId }; for (const listener of listeners) listener(); }; const writeToTail = (): void => { @@ -189,6 +213,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const onTouchEnd = (): void => { touchY = undefined; }; const onScroll = (): void => { awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + readingTurnId = readTurn(); if (gesture) { const delta = target.scrollTop - gesture.top; gesture.top = target.scrollTop; @@ -247,20 +272,28 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // outside Turns. Resize changes position only; it never changes intent. const box = new ResizeObserver(() => { if (pinned && !gesture) writeToTail(); - else { - awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; - publish(); - } + else awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + // Turns mounting, unmounting and growing all reach this before they + // reach any scroll event, so this is where the reading position moves + // when the reader does not. + readingTurnId = readTurn(); + publish(); }); const observeBox = (): void => { box.disconnect(); box.observe(target); for (const child of target.children) box.observe(child); }; - const childList = new MutationObserver(observeBox); + const childList = new MutationObserver(() => { + observeBox(); + readingTurnId = readTurn(); + publish(); + }); childList.observe(target, { childList: true }); observeBox(); if (pinned) writeToTail(); + readingTurnId = readTurn(); + publish(); return () => { childList.disconnect(); box.disconnect(); @@ -297,6 +330,11 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { readerListeners.add(listener); return () => { readerListeners.delete(listener); }; }, + measureReadingTurn() { + readingTurnId = readTurn(); + publish(); + return readingTurnId; + }, subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 8cc9116b14..0990e0baee 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -54,9 +54,11 @@ export function useChatScroll(input: { onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; hasOlderHistory?: boolean; - onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; + onLoadEarlierHistory?(): Promise | void; hasNewerHistory?: boolean; - onLoadLaterHistory?(anchorTurnId?: string): Promise | void; + onLoadLaterHistory?(): Promise | void; + /** The turns the reader can still reach within the retained band; the rest may go. */ + onRetainWindow?(window: { firstTurnId: string; lastTurnId: string }): void; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); const authority = useTranscriptScrollAuthority(); @@ -66,6 +68,8 @@ export function useChatScroll(input: { const loadLaterRef = useRef(input.onLoadLaterHistory); loadLaterRef.current = input.onLoadLaterHistory; const canLoadLater = input.onLoadLaterHistory !== undefined; + const retainRef = useRef(input.onRetainWindow); + retainRef.current = input.onRetainWindow; const handledTarget = useRef(null); const anchorChangeRef = useRef(input.onReadingAnchorChange); anchorChangeRef.current = input.onReadingAnchorChange; @@ -133,9 +137,7 @@ export function useChatScroll(input: { // actually landed, neither an intermediate bounded range nor an empty // one says anything new about where the reader intended to be. if (commandTarget.current && handledTarget.current !== commandTarget.current) return; - const turnId = snapshot.pinned - ? undefined - : firstVisibleTurnId(input.scrollRef.current); + const turnId = snapshot.pinned ? undefined : authority.measureReadingTurn(); // An empty bounded range has no new reading position. In particular, // releasing the pin before a remembered range loads must not erase the // Turn that caused that range to be requested. @@ -171,55 +173,73 @@ export function useChatScroll(input: { }; }, [authority, input.scrollRef, input.sessionId]); + // The window is a band of pixels around the reader: fetch when less than two + // screens remain in a direction that has history, drop what lies more than + // six screens away and keep four. Both are re-evaluated whenever content or + // the reader moves, so the tail prefetches its history and a reader who + // stops mid-transcript never pins more than a bounded slice in memory. + const bandCheck = useRef<(() => void) | undefined>(undefined); useEffect(() => { const root = input.scrollRef.current; if (!root) return; + const inFlight = { up: false, down: false }; const canLoad = (direction: 'up' | 'down'): boolean => direction === 'up' ? input.hasOlderHistory === true && canLoadEarlier : input.hasNewerHistory === true && canLoadLater; - // Asking twice is the loader's problem, not this one's: it refuses a - // request while one is in flight, and asking for history the reader - // already has is idempotent anyway. const requestHistory = (direction: 'up' | 'down'): void => { - activation.current = { sessionId: input.sessionId }; - commandTarget.current = null; - // Moving input already released following. Only an immovable edge can - // still be pinned; do not cancel the input operation that requested data. - if (authority.getSnapshot().pinned) authority.releasePin(); - // A wheel at either edge moves nothing, so no scroll event refreshes the - // anchor and the restore effect would load around an evicted Turn. - reportReadingAnchor.current?.(); - const anchorTurnId = direction === 'up' - ? firstVisibleTurnId(root) - : lastVisibleTurnId(root); + if (inFlight[direction]) return; // The browser anchors the reader against everything that lands above - // them, with one exception: it declines while the scroller sits at zero, - // which is exactly where a wheel asks for history. One pixel is the whole - // fix — measured in Chromium, an insert of 501px above the reader moves - // `scrollTop` by 501 at an offset of 1 and by 0 at an offset of 0. - if (direction === 'up' && root.scrollTop < 1) root.scrollTop = 1; + // them, with one exception: it declines while the scroller sits at zero. + // One pixel is the whole fix — measured in Chromium, an insert of 501px + // above the reader moves `scrollTop` by 501 at an offset of 1 and by 0 + // at an offset of 0. + if (direction === 'up' && !authority.getSnapshot().pinned && root.scrollTop < 1) { + root.scrollTop = 1; + } const load = direction === 'up' ? loadEarlierRef.current : loadLaterRef.current; - void Promise.resolve(load?.(anchorTurnId)).catch(() => undefined); + inFlight[direction] = true; + void Promise.resolve(load?.()).catch(() => undefined).finally(() => { + inFlight[direction] = false; + check(); + }); + }; + const check = (): void => { + if (!root.isConnected || bandCheck.current !== check) return; + const screen = Math.max(320, root.clientHeight); + const above = root.scrollTop; + const below = root.scrollHeight - root.clientHeight - root.scrollTop; + if (canLoad('up') && above < screen * 2) requestHistory('up'); + if (canLoad('down') && below < screen * 2) requestHistory('down'); + if (above <= screen * 6 && below <= screen * 6) return; + const rect = root.getBoundingClientRect(); + const turns = [...root.querySelectorAll('[data-turn-id]')]; + const kept = turns.filter((turn) => { + const box = turn.getBoundingClientRect(); + return box.bottom >= rect.top - screen * 4 && box.top <= rect.bottom + screen * 4; + }); + const first = kept[0]?.dataset.turnId; + const last = kept.at(-1)?.dataset.turnId; + if (!first || !last || kept.length === turns.length) return; + retainRef.current?.({ firstTurnId: first, lastTurnId: last }); + }; + bandCheck.current = check; + // Both phases matter: a gesture at an edge moves nothing and so reports + // only `input`, and that is exactly where the next page is wanted. + const stopWatchingReader = authority.subscribeToReaderScroll(() => check()); + const frame = window.requestAnimationFrame(check); + return () => { + window.cancelAnimationFrame(frame); + if (bandCheck.current === check) bandCheck.current = undefined; + stopWatchingReader(); }; - /** Close enough to the requested edge that the reader is about to reach it. */ - const nearEdge = (direction: 'up' | 'down'): boolean => - (direction === 'up' - ? root.scrollTop - : root.scrollHeight - root.clientHeight - root.scrollTop) - <= Math.max(640, root.clientHeight * 2); - // Nearness alone does not mean the reader wants history — on a transcript - // shorter than about three viewports the tail is inside this band too, so - // following it would ask on every write, and content landing above would - // ask again on every anchoring correction until there was no history left. - // Which movements were the reader's is not re-derived here; the authority - // watches the scroller and says so. - const stopWatchingReader = authority.subscribeToReaderScroll((direction) => { - if (canLoad(direction) && nearEdge(direction)) requestHistory(direction); - }); - return stopWatchingReader; }, [authority, input.hasOlderHistory, input.hasNewerHistory, canLoadEarlier, canLoadLater, input.scrollRef, input.sessionId]); + useEffect(() => { + const frame = window.requestAnimationFrame(() => bandCheck.current?.()); + return () => window.cancelAnimationFrame(frame); + }, [input.messages]); + useEffect(() => { const explicitTarget = input.target?.turnId ? { @@ -239,10 +259,12 @@ export function useChatScroll(input: { : undefined); if (!target) return; if (explicitTarget) activation.current = { sessionId: input.sessionId }; - // This effect re-runs on every transcript update so a target that arrives - // before its turn still lands. It stops for good once the turn is on - // screen — repeating the release afterwards would take the tail away from - // a reader who had already scrolled back to it. + // This effect re-runs on every render so a target that arrives before its + // turn still lands. What mounts that turn is the resident range, which the + // Renderer moves without touching the message list, so no dependency here + // can stand for "the turn may be on screen now". It stops for good once the + // turn is revealed — repeating the release afterwards would take the tail + // away from a reader who had already scrolled back to it. const chosen = target.kind === 'search' ? `search:${input.sessionId ?? ''}:${target.turnId}:${target.nonce}` : restoreCommandKey(input.sessionId, target.turnId, target.unavailable); @@ -257,7 +279,7 @@ export function useChatScroll(input: { if (target.kind !== 'restore' || !target.unavailable) return; handledTarget.current = chosen; activation.current = { sessionId: input.sessionId }; - if (!firstVisibleTurnId(root)) authority.pinToTail(); + if (!authority.measureReadingTurn()) authority.pinToTail(); reportReadingAnchor.current?.(); return; } @@ -290,37 +312,13 @@ export function useChatScroll(input: { window.cancelAnimationFrame(frame); if (clear !== undefined) window.clearTimeout(clear); }; - }, [ - input.target?.turnId, - input.target?.nonce, - input.restoreTarget?.turnId, - input.restoreTarget?.unavailable, - input.behavior, - input.sessionId, - input.messages, - input.scrollRef, - ]); + }); return { highlightedTurnId, }; } -function firstVisibleTurnId(root: HTMLElement | null): string | undefined { - if (!root) return undefined; - const rootTop = root.getBoundingClientRect().top; - return [...root.querySelectorAll('[data-turn-id]')] - .find((turn) => turn.getBoundingClientRect().bottom > rootTop) - ?.dataset.turnId; -} - -function lastVisibleTurnId(root: HTMLElement): string | undefined { - const rootBottom = root.getBoundingClientRect().bottom; - return [...root.querySelectorAll('[data-turn-id]')] - .findLast((turn) => turn.getBoundingClientRect().top < rootBottom) - ?.dataset.turnId; -} - function restoreCommandKey( sessionId: string | undefined, turnId: string, From bba473c27d9cc87c9cde51d0f68f68727ccd22e7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 00:56:48 +0800 Subject: [PATCH 2/4] test(desktop): measure the reader at transcript range boundaries The suite asserted that paging works and stays bounded, and sampled the mounted count only once the range had settled. Neither says where the reader ended up while a page was installing, which is the whole of what #5163 reports. This probe reads every frame, and at each change of the mounted range compares a Turn present on both sides: with no input between two frames its document position must not move, so `Delta top + Delta scrollTop` is zero unless the boundary displaced the reader. Measured settled-to-settled rather than across the changing frames: the range passes through an intermediate commit that mounts far more Turns than it keeps, and scroll anchoring corrects after layout, so a reading taken inside the change reports a correction that never reached the screen. It fails on this branch. Pure trims land at 18px; every page install displaces the reader by about 1800px. Also stops asserting `data-search-highlight` after waiting for the jumped Turn to mount. That highlight clears itself 2.2s after the command lands, so the assertion fails whenever loading the page around the Turn takes longer than the flash - a 3s pass turning into an 18s timeout under load, reproduced 2 of 6 runs. The jump's landing place is read from the reading position instead, which does not expire. Restores the band-check guard removed in the previous commit. A controlled comparison over 6 runs each puts the flake at 2 of 6 without it and 3 of 6 with it, so the ablation that removed it rested on a single passing run and the guard is not what that flake was about. Generated-by: Claude Code --- .../e2e/partial-history-notice.spec.ts | 7 +- .../e2e/transcript-scroll-cost.spec.ts | 181 ++++++++++++++++++ packages/ui/src/use-chat-scroll.ts | 12 ++ 3 files changed, 199 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 1bde533a6d..54e90c26b9 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -46,7 +46,12 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async const firstTurn = page.locator('[data-turn-id="turn-partial-history-1"]'); await expect(firstTurn).toBeVisible(); - await expect(firstTurn).toHaveAttribute('data-search-highlight', 'true'); + // Where the jump landed, read from the reading position rather than from + // `data-search-highlight`: that highlight clears itself 2.2s after the + // command lands, so waiting for the Turn to mount and then asserting it + // fails whenever loading the page around it takes longer than the flash — + // measured here as a 3s pass turning into an 18s timeout under load. + await expect(oldestPrompt).toHaveAttribute('data-active', 'true'); await expect(olderGap).toHaveCount(0); await expect(newerGap).toBeVisible(); await expect(newerGap.getByRole('button', { diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 54458fe3de..e1a275cf3d 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -55,6 +55,16 @@ const TURN = '.maka-transcript-turn'; */ const MOUNTED_TURNS_MAX = 40; +/** + * How far a page boundary is allowed to move the reader, in CSS pixels. + * + * Not a tolerance for "close enough" motion: scroll anchoring corrects in whole + * device pixels while these are read as fractional CSS pixels, so a correct + * frame lands within a pixel of zero and a frame that lost the reader lands a + * Turn away — hundreds. + */ +const DISPLACEMENT_MAX_PX = 2; + declare global { interface Window { __makaTranscriptCost?: { @@ -63,9 +73,29 @@ declare global { skipped: WeakSet; skippedCount: number; }; + __makaTranscriptDisplacement?: { + boundaries: TranscriptBoundary[]; + peakMounted: number; + stop(): void; + }; } } +/** + * One frame where the mounted range changed: a page installed, or the band + * trimmed, or both. + */ +interface TranscriptBoundary { + readonly firstBefore: string; + readonly firstAfter: string; + readonly mountedBefore: number; + readonly mountedAfter: number; + /** Turns present in both frames, so a reader position can be compared. */ + readonly carried: number; + readonly worstTurnId: string | null; + readonly worstPx: number; +} + /** * Real wheel input at the centre of the scroller. Relative by construction: a * wheel tick asks the compositor to move by a delta from wherever the scroller @@ -137,6 +167,100 @@ async function observe(page: Page): Promise { }); } +/** + * Watch every frame for a change in the mounted range, and measure what that + * change did to the reader. + * + * A Turn the reader can still see is at `top` in the viewport and at + * `top + scrollTop` in the document. Between two frames with no input, its + * document position must not move, so `Δtop + ΔscrollTop` is zero — whatever + * the Renderer installed above it, the browser's scroll anchoring absorbed. A + * page that displaces the reader breaks that sum by however tall the rows it + * added or dropped were. + * + * Sampled per frame rather than per gesture: the frame that installs a page is + * the only one where the reader can be lost, and a per-gesture reading would + * subtract the reader's own scrolling back out and see nothing. + */ +async function observeDisplacement(page: Page): Promise { + await page.evaluate((scrollerSelector) => { + const scroller = document.querySelector(scrollerSelector); + if (!scroller) throw new Error('the chat scroll container is missing'); + const read = () => { + const tops = new Map(); + for (const turn of document.querySelectorAll('[data-turn-id]')) { + const turnId = turn.dataset.turnId; + if (turnId) tops.set(turnId, turn.getBoundingClientRect().top); + } + return { scrollTop: scroller.scrollTop, tops, key: [...tops.keys()].join(',') }; + }; + const state: { boundaries: unknown[]; peakMounted: number; stop(): void } = { + boundaries: [], + peakMounted: 0, + stop: () => { running = false; }, + }; + let running = true; + let previous = read(); + // The last frame before the range started changing. Held across a run of + // changing frames so the measurement spans settled state to settled state: + // scroll anchoring corrects after layout, so a reading taken inside the + // change would report a correction that never reached the screen. + let settled: ReturnType | null = null; + let peakMounted = 0; + const tick = (): void => { + if (!running) return; + const current = read(); + peakMounted = Math.max(peakMounted, current.tops.size); + state.peakMounted = peakMounted; + if (current.key !== previous.key) { + if (!settled) settled = previous; + } else if (settled) { + const before = settled; + settled = null; + const scrolled = current.scrollTop - before.scrollTop; + let carried = 0; + let worstPx = 0; + let worstTurnId: string | null = null; + for (const [turnId, top] of current.tops) { + const wasAt = before.tops.get(turnId); + if (wasAt === undefined) continue; + carried += 1; + const displaced = Math.abs(top - wasAt + scrolled); + if (displaced > worstPx) { + worstPx = displaced; + worstTurnId = turnId; + } + } + state.boundaries.push({ + firstBefore: before.key.split(',')[0] ?? '', + firstAfter: current.key.split(',')[0] ?? '', + mountedBefore: before.tops.size, + mountedAfter: current.tops.size, + carried, + worstTurnId, + worstPx, + }); + } + previous = current; + requestAnimationFrame(tick); + }; + window.__makaTranscriptDisplacement = state as never; + requestAnimationFrame(tick); + }, SCROLLER); +} + +async function displacement(page: Page): Promise<{ + boundaries: readonly TranscriptBoundary[]; + peakMounted: number; +}> { + return page.evaluate(() => { + const state = window.__makaTranscriptDisplacement; + if (!state) throw new Error('the transcript displacement probe is missing'); + state.stop(); + return { boundaries: state.boundaries, peakMounted: state.peakMounted }; + }); +} + interface CostSample { transitionRuns: number; animationStarts: number; @@ -316,3 +440,60 @@ test('paging back through the whole history keeps the mounted range bounded', as .toHaveCount(1, { timeout: 30_000 }); expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); }); + +/** + * The scenario #5163 was reported from: quit Desktop, start it again, open a + * long Session, and scroll upward through history without stopping. The reader + * perceives stalls or jumps around range boundaries. + * + * The tests above establish that paging works and stays bounded. Neither says + * where the reader ended up while a page was installing, which is the whole of + * what that report is about. This one measures it: every frame the mounted + * range changes, whatever Turn the reader can still see must hold its document + * position. + * + * Displacement in pixels rather than frame timings on purpose — see this file's + * header for what happened to the timing assertions this suite replaced. A + * stall and a jump have the same cause here (a page boundary that moves + * content out from under the reader) and only one of them can be asserted + * without a clock. + */ +test('paging back never moves the reader at a range boundary', async ({ + promptRailWindow: page, +}) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1_000, height: 700 }); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + const cdp = await page.context().newCDPSession(page); + const turns = page.locator('[data-turn-id]'); + await moveToTail(page); + await observeDisplacement(page); + + for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { + const firstBefore = await turns.first().getAttribute('data-turn-id'); + if (firstBefore === 'turn-prompt-rail-1') break; + await expect + .poll(async () => { + await wheel(page, cdp, { ticks: 12, deltaY: -120 }); + return turns.first().getAttribute('data-turn-id'); + }) + .not.toBe(firstBefore); + } + + const { boundaries, peakMounted } = await displacement(page); + // The probe has to have seen the thing it measures: a run that paged nothing, + // or one where every boundary replaced the range wholesale and carried no + // Turn across, proves nothing about the reader. + expect(boundaries.length).toBeGreaterThan(0); + expect(boundaries.filter((boundary) => boundary.carried > 0).length).toBeGreaterThan(0); + + const displaced = boundaries.filter((boundary) => boundary.worstPx > DISPLACEMENT_MAX_PX); + expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`) + .toEqual([]); + // Sampled per frame, not per gesture: the bound above is read once the range + // has stopped moving, so a page that mounts the whole answer and trims it on + // a later frame passes it while costing the reader a full layout of every + // Turn it installed. + expect(peakMounted).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); +}); diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 0990e0baee..826c349f07 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -94,6 +94,8 @@ export function useChatScroll(input: { const restoreUnavailable = input.restoreTarget?.turnId === activation.current?.restoreTurnId && input.restoreTarget?.unavailable === true; + const commandTargetTurnId = useRef(undefined); + commandTargetTurnId.current = input.target?.turnId ?? activation.current?.restoreTurnId; const commandTarget = useRef(null); commandTarget.current = input.target?.turnId ? `search:${input.sessionId ?? ''}:${input.target.turnId}:${input.target.nonce}` @@ -213,6 +215,16 @@ export function useChatScroll(input: { if (above <= screen * 6 && below <= screen * 6) return; const rect = root.getBoundingClientRect(); const turns = [...root.querySelectorAll('[data-turn-id]')]; + // A mounted Turn that a pending command is about to reveal must survive + // this pass: a page installs it several screens from the reader, so the + // band would trim it before the frame that scrolls to it ever runs, and + // the command would never land. A target that is not mounted cannot be + // trimmed anyway, and waiting for it would let the window grow unbounded. + const pending = commandTarget.current !== null + && handledTarget.current !== commandTarget.current + ? commandTargetTurnId.current + : undefined; + if (pending && turns.some((turn) => turn.dataset.turnId === pending)) return; const kept = turns.filter((turn) => { const box = turn.getBoundingClientRect(); return box.bottom >= rect.top - screen * 4 && box.top <= rect.bottom + screen * 4; From aedc843abd9a943a3d8824cd674186306f684ceb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 01:51:51 +0800 Subject: [PATCH 3/4] fix(desktop): scope transcript window invalidation to what each read assumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window has five writers — command answers, replica replacements, tail broadcasts, band trims and automatic fills — but only command answers were invalidatable, and the automatic fill borrowed the reader's own navigation channel. Five reported defects fall out of those two gaps. A read is answerable only while what it assumed still holds, and the two kinds of read assume different things. An extension splices rows onto one edge, so any replacement of that edge — navigating away, or the band trimming it out — makes its answer unable to reach what is left; installing it would open a hole the contiguous-window model cannot express and no edge cursor can name. A replacement discards the edges, so only a newer navigation makes it stale, and a replica replacement that answers nothing this window asked for is admitted whole rather than misfiltered as a superseded command. Filling an edge is not navigating to it: it gets its own channel, so it no longer consumes the reader's outstanding jump, and it answers whether the window moved so a read that changed nothing is not reissued in its own callback, forever. Generated-by: Claude Code --- apps/desktop/e2e-budget.json | 4 +- .../e2e/transcript-scroll-cost.spec.ts | 77 ++++++++++++---- .../desktop-transcript-range-store.test.ts | 10 +- ...me-host-session-execution-ipc-main.test.ts | 4 +- .../runtime-host-session-observer.test.ts | 26 +++--- .../transcript-navigation-race.test.ts | 70 ++++++++++---- ...script-reading-position-controller.test.ts | 30 ++++-- ...ub-coordination-transcript-preload.test.ts | 26 +++--- .../src/main/desktop-transcript-ipc.ts | 6 +- .../src/main/desktop-transcript-replica.ts | 2 +- ...runtime-host-session-execution-ipc-main.ts | 4 +- .../src/main/runtime-host-session-observer.ts | 38 +++++--- apps/desktop/src/preload/preload.ts | 6 +- .../src/preload/transcript-contract.ts | 33 ++++--- apps/desktop/src/renderer/app-shell.tsx | 2 + .../src/renderer/chat-message-surface.tsx | 3 + ...transcript-reading-position-controller.tsx | 20 ++++ .../desktop/desktop-transcript-range-store.ts | 92 +++++++++++++------ .../ui/src/__tests__/use-chat-scroll.test.tsx | 83 ++++++++++++++++- packages/ui/src/chat-view.tsx | 5 +- packages/ui/src/use-chat-scroll.ts | 44 +++++---- 21 files changed, 417 insertions(+), 168 deletions(-) diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 8bd366b228..255c207801 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -66,8 +66,8 @@ "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" }, "transcript-scroll-cost.spec.ts": { - "tests": 3, - "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping" + "tests": 4, + "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping, and reader displacement only exists against a real layout" }, "workhub-layout.spec.ts": { "tests": 2, diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index e1a275cf3d..d6eccfbdc3 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -56,14 +56,15 @@ const TURN = '.maka-transcript-turn'; const MOUNTED_TURNS_MAX = 40; /** - * How far a page boundary is allowed to move the reader, in CSS pixels. + * How far a range boundary is allowed to move the reader, in CSS pixels. * - * Not a tolerance for "close enough" motion: scroll anchoring corrects in whole - * device pixels while these are read as fractional CSS pixels, so a correct - * frame lands within a pixel of zero and a frame that lost the reader lands a - * Turn away — hundreds. + * A boundary both installs a page and drops the far side of the band, and the + * two settle within the same quiet frame, so what is measurable is their sum. + * Not a tolerance for "close enough" motion: anchoring holds that sum to a + * fraction of a Turn — 18px here, unchanged by this work — where a frame that + * lost the reader lands a Turn away or more. */ -const DISPLACEMENT_MAX_PX = 2; +const BOUNDARY_DISPLACEMENT_MAX_PX = 40; declare global { interface Window { @@ -90,6 +91,10 @@ interface TranscriptBoundary { readonly firstAfter: string; readonly mountedBefore: number; readonly mountedAfter: number; + readonly grewPx: number; + readonly scrolledPx: number; + readonly olderGapPx: string; + readonly newerGapPx: string; /** Turns present in both frames, so a reader position can be compared. */ readonly carried: number; readonly worstTurnId: string | null; @@ -171,12 +176,12 @@ async function observe(page: Page): Promise { * Watch every frame for a change in the mounted range, and measure what that * change did to the reader. * - * A Turn the reader can still see is at `top` in the viewport and at - * `top + scrollTop` in the document. Between two frames with no input, its - * document position must not move, so `Δtop + ΔscrollTop` is zero — whatever - * the Renderer installed above it, the browser's scroll anchoring absorbed. A - * page that displaces the reader breaks that sum by however tall the rows it - * added or dropped were. + * What must not move is where a Turn sits ON SCREEN, so the measurement is its + * viewport `top` and nothing else. Its position in the DOCUMENT is expected to + * move — installing a page above the reader is exactly what shifts it — and + * scroll anchoring answers that by adding the same amount to `scrollTop`, which + * is why the reader sees nothing. Measuring the document position instead would + * report every correctly absorbed page as a displacement the size of the page. * * Sampled per frame rather than per gesture: the frame that installs a page is * the only one where the reader can be lost, and a per-gesture reading would @@ -192,7 +197,18 @@ async function observeDisplacement(page: Page): Promise { const turnId = turn.dataset.turnId; if (turnId) tops.set(turnId, turn.getBoundingClientRect().top); } - return { scrollTop: scroller.scrollTop, tops, key: [...tops.keys()].join(',') }; + const gap = (direction: string): number => { + const row = document.querySelector(`[data-transcript-gap="${direction}"]`); + return row ? row.getBoundingClientRect().height : 0; + }; + return { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + olderGap: gap('older'), + newerGap: gap('newer'), + tops, + key: [...tops.keys()].join(','), + }; }; const state: { boundaries: unknown[]; peakMounted: number; stop(): void } = { boundaries: [], @@ -200,6 +216,14 @@ async function observeDisplacement(page: Page): Promise { stop: () => { running = false; }, }; let running = true; + // Only frames the reader is not currently scrolling through can be + // compared: a wheel tick moves every Turn on screen by its own delta, which + // is indistinguishable from a page that moved them. The reader's hand stops + // between gestures, and a page that lands then is exactly the one they see + // jump. + let lastWheelAt = -Infinity; + const QUIET_MS = 250; + document.addEventListener('wheel', () => { lastWheelAt = performance.now(); }, true); let previous = read(); // The last frame before the range started changing. Held across a run of // changing frames so the measurement spans settled state to settled state: @@ -212,6 +236,12 @@ async function observeDisplacement(page: Page): Promise { const current = read(); peakMounted = Math.max(peakMounted, current.tops.size); state.peakMounted = peakMounted; + if (performance.now() - lastWheelAt < QUIET_MS) { + settled = null; + previous = current; + requestAnimationFrame(tick); + return; + } if (current.key !== previous.key) { if (!settled) settled = previous; } else if (settled) { @@ -225,7 +255,7 @@ async function observeDisplacement(page: Page): Promise { const wasAt = before.tops.get(turnId); if (wasAt === undefined) continue; carried += 1; - const displaced = Math.abs(top - wasAt + scrolled); + const displaced = Math.abs(top - wasAt); if (displaced > worstPx) { worstPx = displaced; worstTurnId = turnId; @@ -236,6 +266,10 @@ async function observeDisplacement(page: Page): Promise { firstAfter: current.key.split(',')[0] ?? '', mountedBefore: before.tops.size, mountedAfter: current.tops.size, + grewPx: current.scrollHeight - before.scrollHeight, + scrolledPx: scrolled, + olderGapPx: `${before.olderGap}->${current.olderGap}`, + newerGapPx: `${before.newerGap}->${current.newerGap}`, carried, worstTurnId, worstPx, @@ -476,6 +510,10 @@ test('paging back never moves the reader at a range boundary', async ({ await expect .poll(async () => { await wheel(page, cdp, { ticks: 12, deltaY: -120 }); + // Let the hand come off the wheel. A page requested by this gesture + // lands here, in the quiet the probe measures across — which is also + // when a reader would see it move. + await page.waitForTimeout(150); return turns.first().getAttribute('data-turn-id'); }) .not.toBe(firstBefore); @@ -488,12 +526,11 @@ test('paging back never moves the reader at a range boundary', async ({ expect(boundaries.length).toBeGreaterThan(0); expect(boundaries.filter((boundary) => boundary.carried > 0).length).toBeGreaterThan(0); - const displaced = boundaries.filter((boundary) => boundary.worstPx > DISPLACEMENT_MAX_PX); + const displaced = boundaries.filter((boundary) => boundary.worstPx > BOUNDARY_DISPLACEMENT_MAX_PX); expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`) .toEqual([]); - // Sampled per frame, not per gesture: the bound above is read once the range - // has stopped moving, so a page that mounts the whole answer and trims it on - // a later frame passes it while costing the reader a full layout of every - // Turn it installed. - expect(peakMounted).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); + // What the window holds is bounded in pixels, and the tests above already + // hold it to that. Reported here only so a boundary that moved the reader can + // be read against how much the range was carrying when it did. + expect(peakMounted).toBeGreaterThan(0); }); diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 798ece654a..0d3472f688 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -209,9 +209,9 @@ test('cached reload snapshots allow the same live transcript generation to resum }; let opens = 0; const deliveries: Array<{ generation: string; accepted: boolean }> = []; - const publish = (generation: string, text: string, navigationVersion = 0) => { + const publish = (generation: string, text: string, windowEpoch = 0) => { for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, generation, navigationVersion, durableThrough: 1, + ...identity, generation, windowEpoch, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(text) }], overlay: [], hasOlder: false, hasNewer: false, })) deliveries.push({ generation, accepted: store.accept(batch) }); @@ -229,10 +229,10 @@ test('cached reload snapshots allow the same live transcript generation to resum async loadBefore() {}, async loadAfter() {}, async loadAround(_sequence, _maxBytes, navigation) { - publish(identity.generation, `live-${opens}`, navigation?.navigationVersion); + publish(identity.generation, `live-${opens}`, navigation?.windowEpoch); }, async loadLatest(navigation) { - publish(identity.generation, `live-${opens}`, navigation?.navigationVersion); + publish(identity.generation, `live-${opens}`, navigation?.windowEpoch); }, async close() {}, }; @@ -913,7 +913,7 @@ test('forwards a larger logical history range without changing batch size', asyn sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - navigationVersion: 0, + windowEpoch: 0, durableThrough: 4, durable: [ { sequence: 1, message: assistantMessage('earlier') }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 5bb73dcbad..9dd2c4b0b3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -104,7 +104,7 @@ test('window transcript reads are observation operations scoped to the renderer' registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); const request = { consumerId: 'guest-consumer', sessionId: 'shared-session', hostEpoch: 'host-1', - anchorSequence: 42, maxBytes: 512 * 1024, navigationVersion: 7, + anchorSequence: 42, maxBytes: 512 * 1024, windowEpoch: 7, }; await ipc.invoke('sessions:transcript:load-after', request); await ipc.invoke('sessions:transcript:load-latest', { ...request, anchorSequence: null }); @@ -118,7 +118,7 @@ test('window transcript reads are observation operations scoped to the renderer' ); // The Renderer owns the window, so every read must name the version it reads for. await assert.rejects( - ipc.invoke('sessions:transcript:load-after', { ...request, navigationVersion: undefined }), + ipc.invoke('sessions:transcript:load-after', { ...request, windowEpoch: undefined }), /Invalid Desktop transcript navigation/, ); assert.equal(calls.length, 2); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index b2be49ef5f..3294f7fb48 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -565,7 +565,7 @@ test('does not hold Host observation recovery on transcript replay', async () => hostEpoch: 'host-second', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion: 1, + windowEpoch: 1, }, transcriptTarget.id, ); @@ -615,7 +615,7 @@ test('fences transcript range failures to the current registration and Host sour hostEpoch: `host-${generation}`, anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion: 1, + windowEpoch: 1, }); const closedFailure = deferred(); @@ -732,7 +732,7 @@ test('fences transcript range failures across same-source replica recovery', asy hostEpoch, anchorSequence: 1, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion: 1, + windowEpoch: 1, }); const target: RuntimeHostTranscriptTarget = { id: 20, @@ -1128,7 +1128,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion: 1, + windowEpoch: 1, }, 22, ), @@ -1147,7 +1147,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'other-host', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion: 1, + windowEpoch: 1, }, 22, ), @@ -1162,7 +1162,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion: 1, + windowEpoch: 1, }, 22, ), @@ -1264,7 +1264,7 @@ test('coalesces transcript changes into one bounded delta while renderer deliver assert.equal(batches[1]!.reset, false); assert.equal(batches[1]!.durableThrough, 4); assert.equal(batches[1]!.fragments.length, 4); - assert.equal(batches[1]!.navigationVersion, undefined, 'tail growth is a broadcast, not an answer'); + assert.equal(batches[1]!.windowEpoch, undefined, 'tail growth is a broadcast, not an answer'); assert.equal(batches[1]!.hasOlder, undefined); assert.equal(batches[1]!.hasNewer, undefined); observer.acknowledgeTranscript( @@ -1344,13 +1344,13 @@ test('answers a window page read on its own navigation version and drops a stale }); const batches: DesktopTranscriptBatch[] = []; const consumerId = 'consumer-window'; - const request = (navigationVersion: number, anchorSequence: number | null) => ({ + const request = (windowEpoch: number, anchorSequence: number | null) => ({ consumerId, sessionId: 'session-1', hostEpoch: 'host-1', anchorSequence, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion, + windowEpoch, }); await observer.openTranscript('session-1', consumerId, { id: 26, @@ -1367,7 +1367,7 @@ test('answers a window page read on its own navigation version and drops a stale await observer.loadTranscriptBefore(request(1, 2), 26); assert.equal(batches.length, 1); - assert.equal(batches[0]!.navigationVersion, 1); + assert.equal(batches[0]!.windowEpoch, 1); assert.equal(batches[0]!.reset, false, 'extending the window does not replace it'); assert.equal(batches[0]!.hasOlder, true); assert.equal(batches[0]!.hasNewer, undefined, 'an older page establishes only its older edge'); @@ -1375,13 +1375,13 @@ test('answers a window page read on its own navigation version and drops a stale // The same version extends the window the Renderer already holds. await observer.loadTranscriptAfter(request(1, 1), 26); assert.equal(batches.length, 2); - assert.equal(batches[1]!.navigationVersion, 1); + assert.equal(batches[1]!.windowEpoch, 1); assert.equal(batches[1]!.reset, false); assert.equal(batches[1]!.hasNewer, false); await observer.loadTranscriptAround(request(2, 1), 26); assert.equal(batches.length, 3); - assert.equal(batches[2]!.navigationVersion, 2); + assert.equal(batches[2]!.windowEpoch, 2); assert.equal(batches[2]!.reset, true, 'a window-replacing command resets the Renderer'); assert.equal(batches[2]!.hasOlder, false); assert.equal(batches[2]!.hasNewer, true); @@ -1586,7 +1586,7 @@ test('keeps a transcript consumer available after a delivery fails', async () => hostEpoch: opened.hostEpoch, anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigationVersion: 1, + windowEpoch: 1, }, 25, ), diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts index 7bfb1865ef..fdc25b3f05 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -23,7 +23,7 @@ import type { StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; @@ -117,14 +117,14 @@ test('a global cache trim empties the tail without publishing or reading history test('a superseded fragmented reset cannot clear or complete the next navigation', () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); acceptSnapshot(store, 0, 'generation-1', [record(1)]); - store.expectNavigation(1); + store.replaceWindow(); const stale = [...encodeDesktopTranscriptSnapshot({ - ...identity, navigationVersion: 1, durableThrough: 1, + ...identity, windowEpoch: 1, durableThrough: 1, durable: [{ sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }], overlay: [], hasOlder: false, hasNewer: true, })]; assert.equal(store.accept(stale[0]!), false); - store.expectNavigation(2); + store.replaceWindow(); acceptSnapshot(store, 2, 'generation-2', [record(1)]); const committed = store.snapshot(); for (const batch of stale) assert.equal(store.accept(batch), false); @@ -135,21 +135,55 @@ test('a superseded fragmented reset cannot clear or complete the next navigation assert.strictEqual(store.snapshot(), committed); }); +test('a replica replacement is admitted whole, however far the window has navigated', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + acceptSnapshot(store, 0, 'generation-1', [record(1)]); + store.replaceWindow(); + const replacement = [...encodeDesktopTranscriptSnapshot({ + ...identity, generation: 'generation-2', durableThrough: 1, + durable: [ + { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, + { sequence: 1, message: record(1).message }, + ], + overlay: [], hasOlder: false, hasNewer: false, + })]; + assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); + for (const batch of replacement) store.accept(batch); + assert.equal(store.range().ready, true); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0', 'message-1']); +}); + +test('a page anchored on an edge the band has since dropped is refused', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + acceptSnapshot(store, 0, 'generation-1', [record(1), record(2), record(3)]); + const anchored = store.windowEpoch(); + assert.equal(store.retain(3, 3), true); + const answer = [...encodeDesktopTranscriptPage({ ...identity, windowEpoch: anchored }, { + durableThrough: 3, durable: [{ sequence: 0, message: record(0).message }], + hasOlder: false, + })]; + for (const batch of answer) assert.equal(store.accept(batch), false); + // Installing it would leave 1..2 missing between the answer and the window, + // and no edge cursor can name a hole in the middle. + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-3']); + assert.equal(store.range().hasOlder, true); +}); + test('follow latest invalidates an in-flight history navigation before open resolves', async () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); const opening = deferred(); - const requests: Array<{ command: 'around' | 'latest'; anchor: number | null; navigationVersion: number }> = []; + const requests: Array<{ command: 'around' | 'latest'; anchor: number | null; windowEpoch: number }> = []; const handle = (generation: string): DesktopTranscriptHandle => ({ ...identity, generation, readThroughMessageId: null, async loadBefore() { assert.fail('an obsolete history request was replayed'); }, async loadAfter() { assert.fail('an obsolete newer request was replayed'); }, async loadAround(anchor, _bytes, navigation) { - requests.push({ command: 'around', anchor, navigationVersion: navigation.navigationVersion }); - acceptSnapshot(store, navigation.navigationVersion, generation, [record(0)]); + requests.push({ command: 'around', anchor, windowEpoch: navigation.windowEpoch }); + acceptSnapshot(store, navigation.windowEpoch, generation, [record(0)]); }, async loadLatest(navigation) { - requests.push({ command: 'latest', anchor: null, navigationVersion: navigation.navigationVersion }); - acceptSnapshot(store, navigation.navigationVersion, generation, [record(1)]); + requests.push({ command: 'latest', anchor: null, windowEpoch: navigation.windowEpoch }); + acceptSnapshot(store, navigation.windowEpoch, generation, [record(1)]); }, async close() {}, }); @@ -158,7 +192,7 @@ test('follow latest invalidates an in-flight history navigation before open reso const latest = controller.loadLatest(); opening.resolve(handle('generation-1')); await Promise.all([history, latest]); - assert.deepEqual(requests, [{ command: 'latest', anchor: null, navigationVersion: 2 }]); + assert.deepEqual(requests, [{ command: 'latest', anchor: null, windowEpoch: 2 }]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); await controller.close(); }); @@ -177,7 +211,7 @@ test('a rejected older navigation cannot fail the newer latest command', async ( await historyResult; }, async loadLatest(navigation) { - acceptSnapshot(store, navigation.navigationVersion, identity.generation, [record(1)]); + acceptSnapshot(store, navigation.windowEpoch, identity.generation, [record(1)]); }, async close() {}, })); @@ -225,7 +259,7 @@ test('superseded batches remain ACKable and cannot reset the latest window while id: 1, once() {}, off() {}, send(_channel, batch) { store.accept(batch); - if (batch.navigationVersion === 1 && !releaseAcks) { + if (batch.windowEpoch === 1 && !releaseAcks) { blocked.push(batch); firstOldBatch.resolve(); } else queueMicrotask(() => ack(batch)); @@ -233,13 +267,13 @@ test('superseded batches remain ACKable and cannot reset the latest window while }); const request: DesktopTranscriptRangeRequest = { consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: PAGE_BYTES, navigationVersion: 1, + anchorSequence: 0, maxBytes: PAGE_BYTES, windowEpoch: 1, }; - store.expectNavigation(1); + store.replaceWindow(); const history = observer.loadTranscriptAround(request, 1); await firstOldBatch.promise; - store.expectNavigation(2); - const following = observer.loadTranscriptLatest({ ...request, navigationVersion: 2, anchorSequence: null }, 1); + store.replaceWindow(); + const following = observer.loadTranscriptLatest({ ...request, windowEpoch: 2, anchorSequence: null }, 1); releaseAcks = true; for (const batch of blocked) ack(batch); await Promise.all([history, following]); @@ -251,9 +285,9 @@ test('superseded batches remain ACKable and cannot reset the latest window while }); const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; -function acceptSnapshot(store: DesktopTranscriptRangeStore, navigationVersion: number, generation: string, records: Array>) { +function acceptSnapshot(store: DesktopTranscriptRangeStore, windowEpoch: number, generation: string, records: Array>) { for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, navigationVersion, generation, durableThrough: 1, + ...identity, windowEpoch, generation, durableThrough: 1, durable: records.map(({ identity: sequence, message }) => ({ sequence, message })), overlay: [], hasOlder: true, hasNewer: false, })) store.accept(batch); diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 20c098259d..2f22685add 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -22,7 +22,7 @@ import { afterEach, test } from 'node:test'; import { act, createElement, createRef, type ComponentProps } from 'react'; import { deferred } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; -import type { DesktopTranscriptHandle, DesktopTranscriptNavigation } from '../../preload/transcript-contract.js'; +import type { DesktopTranscriptHandle, DesktopTranscriptWindowRead } from '../../preload/transcript-contract.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { @@ -46,13 +46,13 @@ test('sending before transcript open completes supersedes the queued bookmark wi const opening = deferred(); const controller = createDesktopTranscriptRangeController(store, () => opening.promise); const lifecycle = createTranscriptRestoreLifecycle(); - const requests: Array<{ sequence: number | null; navigation?: DesktopTranscriptNavigation }> = []; - const publish = (sequence: number | null, navigation: DesktopTranscriptNavigation) => { + const requests: Array<{ sequence: number | null; navigation?: DesktopTranscriptWindowRead }> = []; + const publish = (sequence: number | null, navigation: DesktopTranscriptWindowRead) => { requests.push({ sequence, navigation }); const turnId = sequence === null ? 'b' : 'a'; for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - navigationVersion: navigation.navigationVersion, durableThrough: 20, + windowEpoch: navigation.windowEpoch, durableThrough: 20, durable: [{ sequence: sequence ?? 20, message: { type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, @@ -85,12 +85,12 @@ test('sending before transcript open completes supersedes the queued bookmark wi restore(); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(requests.map(({ sequence, navigation }) => - [sequence, navigation?.navigationVersion]), [[null, 2]]); + [sequence, navigation?.windowEpoch]), [[null, 2]]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['answer-b']); const latest = store.snapshot(); for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - navigationVersion: 1, durableThrough: 20, + windowEpoch: 1, durableThrough: 20, durable: [{ sequence: 10, message: { type: 'assistant', id: 'answer-a', turnId: 'a', text: 'a', ts: 1, modelId: 'fixture', } }], overlay: [], hasOlder: false, hasNewer: true, @@ -109,7 +109,7 @@ test('an overlay-only bookmark stays available without loading another range', a type: 'assistant', id: 'answer-b', turnId: 'b', text: 'partial B', ts: 1, modelId: 'fixture', }; for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion: 0, + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', windowEpoch: 0, durableThrough: null, durable: [], overlay: [overlay], hasOlder: false, hasNewer: false, })) store.accept(batch); const controller = createDesktopTranscriptRangeController(store, async () => ({ @@ -192,6 +192,22 @@ test('an old Session history load cannot report or clear the new Session state', assert.equal(fixture.pending(), undefined); }); +test('filling an edge leaves an outstanding jump and its pending state alone', async () => { + const fixture = controllerFixture(); + let cleared = 0; + fixture.props.searchTarget = { sessionId: 'session-1', nonce: 1, turnId: 'turn-1' } as never; + fixture.props.clearSearchTarget = () => { cleared += 1; }; + const failure = new Error('the older read failed'); + fixture.controller.loadBefore = async () => { throw failure; }; + await fixture.render(); + + // The jump is still in flight; the band asking for the edge it is scrolling + // towards decides nothing, so it must not answer for the reader. + await assert.rejects(fixture.commands.current!.prefetchHistory('older'), failure); + assert.equal(cleared, 0); + assert.equal(fixture.pending(), undefined); +}); + test('retaining the reader window trims the store to the visible Turns', async () => { const fixture = controllerFixture(); const retained: Array<[number | null, number | null]> = []; diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 2e89efa1f1..cfb0e7975b 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -142,7 +142,7 @@ test('WorkHub projects the exact delegated Turn status and bounded assistant res durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, }; for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, navigationVersion: 0, durable: [{ sequence: 1, message: result }], + ...snapshot, windowEpoch: 0, durable: [{ sequence: 1, message: result }], })) onBatch({ ...batch, deliverySequence: 1 }); return { ...snapshot, readThroughMessageId: result.id, @@ -204,14 +204,14 @@ test('WorkHub proves a long historical Turn tail before caching its final result async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { opens += 1; const emit = ( - navigationVersion: number, + windowEpoch: number, durable: Array<{ sequence: number; message: StoredMessage }>, hasOlder: boolean, hasNewer: boolean, ) => { for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 4, overlay: [], hasOlder, hasNewer, navigationVersion, durable, + durableThrough: 4, overlay: [], hasOlder, hasNewer, windowEpoch, durable, })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); }; emit(0, [{ sequence: 4, message: { ...next, id: 'tail', ts: 4 } }], true, false); @@ -219,13 +219,13 @@ test('WorkHub proves a long historical Turn tail before caching its final result sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', durableThrough: 4, hasOlder: true, hasNewer: false, readThroughMessageId: 'tail', loadBefore: async () => undefined, - async loadAround(_sequence: number | null, _maxBytes: number | undefined, navigation: { navigationVersion: number }) { - emit(navigation.navigationVersion, [{ sequence: 1, message: intermediate }], false, true); + async loadAround(_sequence: number | null, _maxBytes: number | undefined, navigation: { windowEpoch: number }) { + emit(navigation.windowEpoch, [{ sequence: 1, message: intermediate }], false, true); }, - async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: { navigationVersion: number }) { + async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: { windowEpoch: number }) { loadAfters += 1; assert.equal(anchor, 1); - emit(navigation.navigationVersion, [ + emit(navigation.windowEpoch, [ { sequence: 2, message: final }, { sequence: 3, message: next }, ], false, true); @@ -350,7 +350,7 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp if (channel === 'session-local:transcript') return null; if (channel === 'sessions:transcript:open') { consumerId = args[2] as string; - for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, navigationVersion: 0, durable: [] })) { + for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, windowEpoch: 0, durable: [] })) { deliver(batch); } return { ...snapshot, readThroughMessageId: null }; @@ -363,7 +363,7 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp await new Promise((resolve) => setImmediate(resolve)); try { for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, navigationVersion: request.navigationVersion, + ...snapshot, windowEpoch: request.windowEpoch, durable: [{ sequence: 7, message }], })) { deliver(batch); @@ -431,7 +431,7 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp await responseDelivered; await new Promise((resolve) => setImmediate(resolve)); assert.equal(requests.length, 1); - assert.equal(requests[0]!.navigationVersion, 1); + assert.equal(requests[0]!.windowEpoch, 1); assert.equal(requests[0]!.anchorSequence, null); assert.deepEqual(partialProjectionCounts, [1, 1]); assert.deepEqual(projections, [[], ['latest-message']]); @@ -480,9 +480,9 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] sessionId: 'coordination', generation: cached ? 'cached:epoch-1' : `live-${attempt}`, hostEpoch: 'epoch-1', durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, }; - const deliver = (navigationVersion = 0) => { + const deliver = (windowEpoch = 0) => { for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, navigationVersion, + ...snapshot, windowEpoch, durable: [{ sequence: 1, message: { type: 'user', id: cached ? 'cached-message' : 'live-message', turnId: 'turn-1', ts: 1, text: cached ? 'Cached history' : 'Live history' } }], })) onBatch({ ...batch, deliverySequence: 1 }); }; @@ -491,7 +491,7 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] return { ...snapshot, readThroughMessageId: null, loadBefore: unavailable, loadAfter: unavailable, loadLatest: unavailable, - loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation?.navigationVersion), + loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation?.windowEpoch), close: async () => { closedCount++; }, }; }, diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index bb9a8da213..62369b4c41 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -31,7 +31,7 @@ import type { } from './desktop-transcript-replica.js'; interface TranscriptBatchIdentity { - readonly navigationVersion?: number; + readonly windowEpoch?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -105,9 +105,9 @@ function* encodeDesktopTranscriptBatches( fragment = fragments.next(); } yield { - ...(identity.navigationVersion === undefined + ...(identity.windowEpoch === undefined ? {} - : { navigationVersion: identity.navigationVersion }), + : { windowEpoch: identity.windowEpoch }), sessionId: identity.sessionId, generation: identity.generation, hostEpoch: identity.hostEpoch, diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index 6b16318f9b..996ef62c51 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -54,7 +54,7 @@ export interface DesktopSequencedTranscriptMessage { } export interface DesktopTranscriptReplicaSnapshot { - readonly navigationVersion?: number; + readonly windowEpoch?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 2eea4a743c..262ee0f02f 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -833,7 +833,7 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange throw new Error('Invalid Desktop transcript range byte limit'); } if ( - !Number.isSafeInteger(value.navigationVersion) || (value.navigationVersion as number) < 0 + !Number.isSafeInteger(value.windowEpoch) || (value.windowEpoch as number) < 0 ) { throw new Error('Invalid Desktop transcript navigation'); } @@ -843,7 +843,7 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), anchorSequence: anchorSequence as number | null, maxBytes: maxBytes as number, - navigationVersion: value.navigationVersion as number, + windowEpoch: value.windowEpoch as number, }; } diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 32258ae67a..66bdc7a69d 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -130,11 +130,18 @@ interface TranscriptConsumer { readonly consumerId: string; readonly target: RuntimeHostTranscriptTarget; generation: string; - navigationVersion: number; + windowEpoch: number; deliverySequence: number; deliveryBytes: number; deliveryTask?: Promise; resetRequested: boolean; + /** + * Set only when the reset answers a navigation command, and stamped on that + * snapshot so the window can tell its own answer from a replacement it did + * not ask for. A reset from recovery or from an error carries no version: + * the window applies it to whatever it holds, under any navigation. + */ + resetWindowEpoch?: number; /** Page answers queued behind the delivery loop so they never interleave with a change. */ readonly pendingPages: PendingTranscriptPage[]; pendingChange?: PendingTranscriptChange; @@ -153,7 +160,7 @@ interface PendingTranscriptChange { } interface PendingTranscriptPage { - readonly navigationVersion: number; + readonly windowEpoch: number; readonly generation: string; readonly batches: Iterable; readonly encodedBytes: number; @@ -300,7 +307,7 @@ export class RuntimeHostSessionObserver { consumerId, target, generation: replica.generation, - navigationVersion: 0, + windowEpoch: 0, deliverySequence: 0, deliveryBytes: 0, resetRequested: false, @@ -380,7 +387,7 @@ export class RuntimeHostSessionObserver { isCurrent, ); return snapshot && { - batches: encodeDesktopTranscriptSnapshot({ ...snapshot, navigationVersion: request.navigationVersion }), + batches: encodeDesktopTranscriptSnapshot({ ...snapshot, windowEpoch: request.windowEpoch }), bytes: [...snapshot.durable, ...snapshot.overlay.map((message) => ({ message }))], }; }); @@ -393,6 +400,7 @@ export class RuntimeHostSessionObserver { const { state, consumer } = this.#admitTranscriptNavigation(request, targetId); if (!consumer) return; consumer.resetRequested = true; + consumer.resetWindowEpoch = request.windowEpoch; await this.#scheduleTranscriptDelivery(state, consumer); this.#touchReplica(state); } @@ -402,7 +410,7 @@ export class RuntimeHostSessionObserver { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, - navigationVersion: request.navigationVersion, + windowEpoch: request.windowEpoch, }; } @@ -411,14 +419,14 @@ export class RuntimeHostSessionObserver { targetId: number | undefined, ): { state: ObservedSessionState; replica: DesktopTranscriptReplica; consumer?: TranscriptConsumer } { const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); - const version = request.navigationVersion; + const version = request.windowEpoch; if (!Number.isSafeInteger(version) || version < 0) throw new Error('Invalid transcript navigation version'); // A window-replacing command mints a new version; a page that extends the // current window reuses it. Anything older belongs to a window the // Renderer already abandoned. - if (version < consumer.navigationVersion) return { state, replica }; - if (version > consumer.navigationVersion) { - consumer.navigationVersion = version; + if (version < consumer.windowEpoch) return { state, replica }; + if (version > consumer.windowEpoch) { + consumer.windowEpoch = version; consumer.pendingPages.splice(0).forEach((page) => this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), ); @@ -442,7 +450,7 @@ export class RuntimeHostSessionObserver { const isCurrent = () => state.replica === replica && state.transcriptConsumers.get(request.consumerId) === consumer && - consumer.navigationVersion === request.navigationVersion; + consumer.windowEpoch === request.windowEpoch; let answer: Awaited>; try { answer = await operation(replica, isCurrent); @@ -460,7 +468,7 @@ export class RuntimeHostSessionObserver { throw new Error('Desktop transcript delivery capacity was reached'); } consumer.pendingPages.push({ - navigationVersion: request.navigationVersion, + windowEpoch: request.windowEpoch, generation: replica.generation, batches: answer.batches, encodedBytes, @@ -1244,6 +1252,8 @@ export class RuntimeHostSessionObserver { while (state.transcriptConsumers.get(consumer.consumerId) === consumer) { if (consumer.resetRequested) { consumer.resetRequested = false; + const resetWindowEpoch = consumer.resetWindowEpoch; + consumer.resetWindowEpoch = undefined; this.#clearPendingTranscriptChange(consumer); const replica = state.replica; if (!replica?.resident || state.closing) return; @@ -1257,7 +1267,7 @@ export class RuntimeHostSessionObserver { consumer, encodeDesktopTranscriptSnapshot({ ...replica.snapshot(), - navigationVersion: consumer.navigationVersion, + windowEpoch: resetWindowEpoch, }), ); } finally { @@ -1269,7 +1279,7 @@ export class RuntimeHostSessionObserver { if (page) { try { if ( - page.navigationVersion === consumer.navigationVersion && + page.windowEpoch === consumer.windowEpoch && page.generation === consumer.generation && state.replica?.generation === consumer.generation ) { @@ -1440,7 +1450,7 @@ export class RuntimeHostSessionObserver { ): Promise { const deliveries = new Set>(); for (const batch of batches) { - if (batch.navigationVersion !== undefined && batch.navigationVersion !== consumer.navigationVersion) break; + if (batch.windowEpoch !== undefined && batch.windowEpoch !== consumer.windowEpoch) break; let delivery!: Promise; delivery = this.#deliverTranscriptBatch(consumer, batch).finally(() => { deliveries.delete(delivery); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1c1e1ee193..b0819592be 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -87,7 +87,7 @@ import { type DesktopTranscriptBatch, type DesktopTranscriptHandle, type DesktopTranscriptOpenResult, - type DesktopTranscriptNavigation, + type DesktopTranscriptWindowRead, } from './transcript-contract.js'; import { adoptTranscriptIdentity, @@ -2571,7 +2571,7 @@ const makaBridge = { | 'sessions:transcript:load-latest', anchorSequence: number | null, maxBytes: number, - navigation: DesktopTranscriptNavigation, + navigation: DesktopTranscriptWindowRead, ): Promise => { const currentIdentity = identity; if (!currentIdentity) { @@ -2583,7 +2583,7 @@ const makaBridge = { hostEpoch: currentIdentity.hostEpoch, anchorSequence, maxBytes, - navigationVersion: navigation.navigationVersion, + windowEpoch: navigation.windowEpoch, }) as Promise; }; return { diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index b1d3450ac2..40fd2a4ff6 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -24,8 +24,8 @@ export const DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; -export interface DesktopTranscriptNavigation { - readonly navigationVersion: number; +export interface DesktopTranscriptWindowRead { + readonly windowEpoch: number; } export interface DesktopTranscriptFragment { @@ -38,13 +38,20 @@ export interface DesktopTranscriptFragment { } /** - * A batch answering a range command carries that command's version and the - * edge facts its page established. Broadcast batches (durable catch-up, cache - * trims) carry no version and no edge facts: the Renderer owns the window and - * applies them to whatever it holds. + * A batch answering a read carries the epoch of the window that asked for it, + * plus the edge facts its page established. The window refuses an answer from + * an epoch it has left — it navigated, or trimmed away the very edge the read + * was anchored on — because splicing those rows on would leave a hole between + * them and what the window still holds, and a hole is not something an edge + * cursor can name or a later page can fill. + * + * Batches that carry no epoch are not answers to anything the window asked + * for: tail growth, cache trims, and the snapshot Main sends when it has + * replaced the transcript underneath every window. They apply to whatever the + * window holds, under any epoch. */ export interface DesktopTranscriptBatchPayload { - readonly navigationVersion?: number; + readonly windowEpoch?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -68,7 +75,7 @@ export interface DesktopTranscriptOpenResult { } export interface DesktopTranscriptRangeRequest { - readonly navigationVersion: number; + readonly windowEpoch: number; readonly consumerId: string; readonly sessionId: string; readonly hostEpoch: string; @@ -77,10 +84,10 @@ export interface DesktopTranscriptRangeRequest { } export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { - loadBefore(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptNavigation): Promise; - loadAfter(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptNavigation): Promise; - loadAround(sequence: number, maxBytes: number, navigation: DesktopTranscriptNavigation): Promise; - loadLatest(navigation: DesktopTranscriptNavigation): Promise; + loadBefore(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptWindowRead): Promise; + loadAfter(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptWindowRead): Promise; + loadAround(sequence: number, maxBytes: number, navigation: DesktopTranscriptWindowRead): Promise; + loadLatest(navigation: DesktopTranscriptWindowRead): Promise; close(): Promise; } @@ -91,7 +98,7 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB const batch = value as Record; if ( typeof batch.sessionId !== 'string' || - (batch.navigationVersion !== undefined && !isSequence(batch.navigationVersion)) || + (batch.windowEpoch !== undefined && !isSequence(batch.windowEpoch)) || !isSequence(batch.deliverySequence) || typeof batch.generation !== 'string' || typeof batch.hostEpoch !== 'string' || diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 4a11598a91..1117568717 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2690,6 +2690,8 @@ function AppShellContent({ hasNewerHistory={activeTranscriptRange?.hasNewer} historyLoadPending={historyLoadPending} onLoadHistory={(target) => transcriptReadingCommands.current?.loadHistory(target)} + onPrefetchHistory={(edge) => + transcriptReadingCommands.current?.prefetchHistory(edge) ?? Promise.resolve()} onRetainWindow={(window) => transcriptReadingCommands.current?.retainWindow(window)} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index f95d59db50..f2a200293f 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -95,6 +95,7 @@ interface ChatMessageSurfaceProps extends Omit< hasNewerHistory?: boolean; historyLoadPending?: TranscriptHistoryPending; onLoadHistory: (target: 'earlier' | 'later' | 'latest') => Promise | void; + onPrefetchHistory: (edge: 'older' | 'newer') => Promise; onRetainWindow: (window: { firstTurnId: string; lastTurnId: string }) => void; } @@ -132,6 +133,7 @@ export function ChatMessageSurface({ hasNewerHistory, historyLoadPending, onLoadHistory, + onPrefetchHistory, onRetainWindow, ...chatViewRest }: ChatMessageSurfaceProps) { @@ -256,6 +258,7 @@ export function ChatMessageSurface({ : undefined} onLoadEarlierHistory={() => onLoadHistory('earlier')} onLoadLaterHistory={() => onLoadHistory('later')} + onPrefetchHistory={onPrefetchHistory} onRetainWindow={onRetainWindow} /> )} diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx index 778683cbd1..951a5637a8 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -35,6 +35,7 @@ import { type RangeController = NonNullable>[0]['controller']> & { readonly store: { retain(oldestSequence: number | null, newestSequence: number | null): boolean; + snapshot(): object; }; loadBefore(maxBytes?: number): Promise; loadAfter(maxBytes?: number): Promise; @@ -51,6 +52,7 @@ export interface TranscriptReadingPositionCommands { prepareSend(sessionId: string): Promise; captureAnchor(turnId?: string): void; loadHistory(target: TranscriptHistoryTarget): Promise; + prefetchHistory(edge: 'older' | 'newer'): Promise; retainWindow(window: { firstTurnId: string; lastTurnId: string }): void; } @@ -123,6 +125,24 @@ export function TranscriptReadingPositionController(props: { // A stale range has no window to trim. } }, + /** + * Fills the window at an edge the reader is approaching. Deliberately not + * `loadHistory`: that one cancels restoration and clears the search target, + * because a reader who asks for history has decided where to be. Filling + * decides nothing, so it must leave an outstanding jump alone — the page it + * is waiting for can still be in flight — and it answers whether the window + * moved, so its caller can tell a filled window from a read that failed or + * was refused as stale and would fail again unchanged. + */ + async prefetchHistory(edge) { + const controller = props.rangeController.current; + const { sessionId } = props; + if (!controller || !sessionId || !isCurrent(sessionId, controller)) return false; + const before = controller.store.snapshot(); + if (edge === 'older') await controller.loadBefore(); + else await controller.loadAfter(); + return controller.store.snapshot() !== before; + }, async loadHistory(target) { const controller = props.rangeController.current; const { sessionId } = props; diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index db637c2a0f..75e087a5b5 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -24,7 +24,7 @@ import { type DesktopTranscriptBatchPayload, type DesktopTranscriptFragment, type DesktopTranscriptHandle, - type DesktopTranscriptNavigation, + type DesktopTranscriptWindowRead, } from '../../../preload/transcript-contract.js'; import { projectDesktopStoredMessage } from '../../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; @@ -54,51 +54,54 @@ export function createDesktopTranscriptRangeController( let closed = false; let openController = new AbortController(); let handle = open(openController.signal); - let navigationVersion = 0; - const extending: { older?: Promise; newer?: Promise } = {}; + const extending: { + older?: { epoch: number; task: Promise }; + newer?: { epoch: number; task: Promise }; + } = {}; const current = async () => { if (closed) throw new Error('Desktop transcript range is closed'); return handle; }; const command = async ( replace: boolean, - run: (value: DesktopTranscriptHandle, navigation: DesktopTranscriptNavigation) => Promise, + run: (value: DesktopTranscriptHandle, navigation: DesktopTranscriptWindowRead) => Promise, ) => { - if (replace) { - navigationVersion += 1; - // Invalidate before awaiting an open handle or any in-flight page. - store.expectNavigation(navigationVersion); - } - const version = navigationVersion; + // Mint before awaiting an open handle or any in-flight page. + const windowEpoch = replace ? store.replaceWindow() : store.windowEpoch(); const opening = handle; - const isCurrent = () => !closed && version === navigationVersion && opening === handle; + // A navigation outlives the band trimming the window under it; it is only + // the next navigation that makes this one obsolete. + const epoch = replace ? () => store.navigationEpoch() : () => store.windowEpoch(); + const isCurrent = () => !closed && epoch() === windowEpoch && opening === handle; try { const value = await current(); if (!isCurrent()) return; - await run(value, { navigationVersion: version }); + await run(value, { windowEpoch }); } catch (error) { if (isCurrent()) throw error; } }; const extend = (edge: 'older' | 'newer', maxBytes: number): Promise => { - const pending = extending[edge]; - if (pending) return pending; let range: DesktopTranscriptRangeState; try { range = store.range(); } catch { return Promise.resolve(); } + // Sharing a read only holds while the window it was anchored on does. + const pending = extending[edge]; + if (pending && pending.epoch === store.windowEpoch()) return pending.task; if (edge === 'older' ? !range.hasOlder : !range.hasNewer) return Promise.resolve(); const anchor = edge === 'older' ? range.oldestSequence : range.newestSequence; + const epoch = store.windowEpoch(); const task = command(false, (value, navigation) => edge === 'older' ? value.loadBefore(anchor, maxBytes, navigation) : value.loadAfter(anchor, maxBytes, navigation), ).finally(() => { - if (extending[edge] === task) extending[edge] = undefined; + if (extending[edge]?.task === task) extending[edge] = undefined; }); - extending[edge] = task; + extending[edge] = { epoch, task }; return task; }; return { @@ -300,7 +303,8 @@ export class DesktopTranscriptRangeStore { readonly #durableOrder: number[] = []; readonly #overlayOrder: string[] = []; readonly #pending = new Map(); - #navigationVersion = 0; + #windowEpoch = 0; + #navigationEpoch = 0; readonly #retiredGenerations = new Set(); #sourceSessionId: string | undefined; #generation: string | undefined; @@ -325,28 +329,50 @@ export class DesktopTranscriptRangeStore { this.#expectedSessionId = sessionId; } - expectNavigation(navigationVersion: number): void { - if (navigationVersion <= this.#navigationVersion) return; - this.#navigationVersion = navigationVersion; + windowEpoch(): number { + return this.#windowEpoch; + } + + navigationEpoch(): number { + return this.#navigationEpoch; + } + + /** + * Mints the epoch for a window about to be replaced wholesale by a navigation, + * and drops the partially received records of the window being left behind. + */ + replaceWindow(): number { + this.#navigationEpoch = this.#mintWindow(); + return this.#navigationEpoch; + } + + #mintWindow(): number { + this.#windowEpoch += 1; this.#pending.clear(); this.#batchChanged = false; + return this.#windowEpoch; } /** - * A batch answering a command is current only under the version that issued - * it. A reset for a new replica generation is always current: Main replaced - * the transcript underneath every window. Broadcasts carry no version. + * A read is answerable only while what it assumed still holds, and the two + * kinds of read assume different things. + * + * An extension splices rows onto one edge, so it assumes that edge: any + * replacement of the window — navigating away, or the band trimming the edge + * out — leaves its answer unable to reach what is left, and no edge cursor + * can name the hole it would open. A replacement assumes nothing about the + * edges, because it discards them; only a newer navigation makes it stale. + * + * Batches that name no epoch answer nothing: tail broadcasts apply to + * whatever the window holds, and a snapshot replacing the transcript + * underneath every window is not this window's answer to refuse. */ accepts(batch: DesktopTranscriptBatchPayload): boolean { if (this.#retiredGenerations.has(batch.generation)) return false; if (batch.reset) { - return batch.navigationVersion === undefined || - batch.navigationVersion === this.#navigationVersion || - batch.generation !== this.#liveGeneration; - } - if (batch.navigationVersion !== undefined && batch.navigationVersion !== this.#navigationVersion) { - return false; + return batch.windowEpoch === undefined || batch.windowEpoch === this.#navigationEpoch; } + if (batch.windowEpoch !== undefined && batch.windowEpoch !== this.#windowEpoch) return false; return batch.sessionId === this.#sourceSessionId && batch.generation === this.#generation && batch.hostEpoch === this.#hostEpoch; @@ -356,7 +382,7 @@ export class DesktopTranscriptRangeStore { if (!this.accepts(batch)) return false; if (batch.reset) this.#reset(batch); let changed = batch.reset; - const answersCommand = batch.navigationVersion !== undefined; + const answersCommand = batch.windowEpoch !== undefined; if (batch.hasOlder !== undefined && batch.hasOlder !== this.#hasOlder) { this.#hasOlder = batch.hasOlder; changed = true; @@ -404,6 +430,11 @@ export class DesktopTranscriptRangeStore { /** * Drops durable rows outside `[oldestSequence, newestSequence]`. Either edge * that lost rows becomes a history edge again. + * + * Dropping an edge mints a window epoch: an extension still in flight was + * anchored on that edge, and installing its answer would leave rows missing + * between the answer and what is left — a hole no edge cursor names and no + * later page can fill. */ retain(oldestSequence: number | null, newestSequence: number | null): boolean { let changed = false; @@ -418,6 +449,7 @@ export class DesktopTranscriptRangeStore { changed = true; } if (!changed) return false; + this.#mintWindow(); this.#oldestSequence = this.#durableOrder[0] ?? null; this.#newestSequence = this.#durableOrder.at(-1) ?? null; this.#newestUserSequence = null; diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 377af2166f..f609f1cba8 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -224,8 +224,7 @@ test('history loads follow the reader band, in both directions, once per directi behavior: 'auto', hasOlderHistory: older, hasNewerHistory: newer, - onLoadEarlierHistory: load('up'), - onLoadLaterHistory: load('down'), + onPrefetchHistory: (edge) => load(edge === 'older' ? 'up' : 'down')(), }); return null; } @@ -268,6 +267,82 @@ test('history loads follow the reader band, in both directions, once per directi assert.deepEqual(calls, ['down']); }); +test('a fill that moved nothing is not reissued until the reader moves again', async () => { + const { document, window } = parseHTML( + '
', + ); + installScrollTestEnvironment(document, window, { queueFrames: false }); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 8, + }); + + let requests = 0; + function Harness() { + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-refused', + messages: [{ id: 'message-1' }] as StoredMessage[], + behavior: 'auto', + hasOlderHistory: true, + onPrefetchHistory: () => { + requests += 1; + return Promise.resolve(false); + }, + }); + return null; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render( + , + )); + + await act(async () => { transcript.readerScrollTo(0); }); + // A read refused as stale leaves the window exactly as it was, so asking + // again in its own callback would ask forever. + assert.equal(requests, 1); + await act(async () => {}); + assert.equal(requests, 1); +}); + +test('a failed fill is not reissued until the reader moves again', async () => { + const { document, window } = parseHTML( + '
', + ); + installScrollTestEnvironment(document, window, { queueFrames: false }); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 8, + }); + + let requests = 0; + function Harness() { + const scrollRef = useRef(transcript.scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-failing', + messages: [{ id: 'message-1' }] as StoredMessage[], + behavior: 'auto', + hasOlderHistory: true, + onPrefetchHistory: () => { + requests += 1; + return Promise.reject(new Error('the range read failed')); + }, + }); + return null; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render( + , + )); + + await act(async () => { transcript.readerScrollTo(0); }); + // A failed read leaves the geometry and the history flags exactly as they + // were, so re-checking on its own would ask again forever. + assert.equal(requests, 1); + await act(async () => {}); + assert.equal(requests, 1); +}); + test('an older request at offset zero restores the browser anchoring the reader depends on', async () => { const { document, window } = parseHTML( '
', @@ -286,7 +361,7 @@ test('an older request at offset zero restores the browser anchoring the reader messages: [{ id: 'message-1' }] as StoredMessage[], behavior: 'auto', hasOlderHistory: true, - onLoadEarlierHistory: () => { + onPrefetchHistory: () => { requests += 1; return new Promise(() => undefined); }, @@ -322,7 +397,7 @@ test('a transcript change re-reads the band while the reader stays at the tail', messages, behavior: 'auto', hasOlderHistory: true, - onLoadEarlierHistory: () => { + onPrefetchHistory: () => { requests += 1; return new Promise(() => undefined); }, diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 6f27710c03..6c03a5b11f 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -319,6 +319,8 @@ export function ChatView(props: { historyLoadPending?: TranscriptHistoryLoadDirection; onLoadEarlierHistory?(): Promise | void; onLoadLaterHistory?(): Promise | void; + /** Automatic window filling, kept apart from the two explicit gap actions. */ + onPrefetchHistory?(edge: 'older' | 'newer'): Promise; onRetainWindow?(window: { firstTurnId: string; lastTurnId: string }): void; transcriptTurnIndex?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>; /** Optional identity decorations shared with a host's work navigation. */ @@ -637,9 +639,8 @@ export function ChatView(props: { onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, hasOlderHistory: props.hasOlderHistory, - onLoadEarlierHistory: props.onLoadEarlierHistory, hasNewerHistory: props.hasNewerHistory, - onLoadLaterHistory: props.onLoadLaterHistory, + onPrefetchHistory: props.onPrefetchHistory, onRetainWindow: props.onRetainWindow, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 826c349f07..15af8e1764 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -54,20 +54,23 @@ export function useChatScroll(input: { onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; hasOlderHistory?: boolean; - onLoadEarlierHistory?(): Promise | void; hasNewerHistory?: boolean; - onLoadLaterHistory?(): Promise | void; + /** + * Fills the window because the reader is running out of it — never because + * they asked to go somewhere. It must not consume an outstanding navigation + * intent, and it must reject when the read fails so this hook can tell a + * filled window from a failed one. + */ + /** Fills an edge the reader is approaching; resolves false when it moved nothing. */ + onPrefetchHistory?(edge: 'older' | 'newer'): Promise; /** The turns the reader can still reach within the retained band; the rest may go. */ onRetainWindow?(window: { firstTurnId: string; lastTurnId: string }): void; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); const authority = useTranscriptScrollAuthority(); - const loadEarlierRef = useRef(input.onLoadEarlierHistory); - loadEarlierRef.current = input.onLoadEarlierHistory; - const canLoadEarlier = input.onLoadEarlierHistory !== undefined; - const loadLaterRef = useRef(input.onLoadLaterHistory); - loadLaterRef.current = input.onLoadLaterHistory; - const canLoadLater = input.onLoadLaterHistory !== undefined; + const prefetchRef = useRef(input.onPrefetchHistory); + prefetchRef.current = input.onPrefetchHistory; + const canPrefetch = input.onPrefetchHistory !== undefined; const retainRef = useRef(input.onRetainWindow); retainRef.current = input.onRetainWindow; const handledTarget = useRef(null); @@ -186,8 +189,8 @@ export function useChatScroll(input: { if (!root) return; const inFlight = { up: false, down: false }; const canLoad = (direction: 'up' | 'down'): boolean => direction === 'up' - ? input.hasOlderHistory === true && canLoadEarlier - : input.hasNewerHistory === true && canLoadLater; + ? input.hasOlderHistory === true && canPrefetch + : input.hasNewerHistory === true && canPrefetch; const requestHistory = (direction: 'up' | 'down'): void => { if (inFlight[direction]) return; // The browser anchors the reader against everything that lands above @@ -198,12 +201,21 @@ export function useChatScroll(input: { if (direction === 'up' && !authority.getSnapshot().pinned && root.scrollTop < 1) { root.scrollTop = 1; } - const load = direction === 'up' ? loadEarlierRef.current : loadLaterRef.current; inFlight[direction] = true; - void Promise.resolve(load?.()).catch(() => undefined).finally(() => { - inFlight[direction] = false; - check(); - }); + void Promise.resolve(prefetchRef.current?.(direction === 'up' ? 'older' : 'newer')) + .then( + // Chaining pages needs a re-check here, because the render that the + // landed rows caused ran while this direction still counted as in + // flight. A read that moved nothing — refused as stale, or failed — + // leaves the geometry and the history flags exactly as they were, so + // re-checking would issue the identical request forever. The reader's + // next movement, or the next range change, asks again. + (moved) => { + inFlight[direction] = false; + if (moved !== false) check(); + }, + () => { inFlight[direction] = false; }, + ); }; const check = (): void => { if (!root.isConnected || bandCheck.current !== check) return; @@ -244,7 +256,7 @@ export function useChatScroll(input: { if (bandCheck.current === check) bandCheck.current = undefined; stopWatchingReader(); }; - }, [authority, input.hasOlderHistory, input.hasNewerHistory, canLoadEarlier, canLoadLater, + }, [authority, input.hasOlderHistory, input.hasNewerHistory, canPrefetch, input.scrollRef, input.sessionId]); useEffect(() => { From e8450a1fdd321ecb6dd74fd97ff5bf0d0664bb23 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 11 Sep 2026 07:52:47 +0800 Subject: [PATCH 4/4] feat(desktop)!: drop the transcript range boundary notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice told the reader that the window they are reading does not hold the whole transcript, and gave them a button to extend it. Neither is theirs to care about: the window is a memory budget, the band already fills the edge a reader approaches, and every arrival and departure of the notice is a height change above or below them with no content behind it. It arrived in #4560 without a design decision — the merge's own before/after images do not show it — and #5147 has since had to exclude it from browser scroll anchoring, because a row that is not content was becoming the anchor the reader's position is measured from. Removing it retires that exclusion, the row-projection module whose only job was placing it, its copy in three locales, and the whole explicit history-load path it was the only entry point to: the one navigation a reader still makes is returning to the tail. Removing it also exposed a bug it had been hiding. Returning to the tail cancelled an outstanding bookmark frame only because the notice's pending state forced a render; without that render the queued frame scrolled the reader back to the bookmark they had just left. An explicit pin now outranks a queued restore, which is what the existing regression always claimed to check. Generated-by: Claude Code --- .../e2e/partial-history-notice.spec.ts | 52 ++++------ ...script-reading-position-controller.test.ts | 50 +++------- .../transcript-send-viewport.test.ts | 19 ++-- apps/desktop/src/renderer/app-shell.tsx | 7 +- .../src/renderer/chat-message-surface.tsx | 11 --- ...transcript-reading-position-controller.tsx | 35 ++----- .../controller/transcript-reading-position.ts | 7 -- .../renderer/features/conversation/index.ts | 3 - .../renderer/features/conversation/testing.ts | 1 - .../src/renderer/styles/chat-message.css | 10 +- apps/desktop/stories/app-shell.stories.tsx | 84 +++------------- .../src/__tests__/conversation-copy.test.ts | 21 ---- .../__tests__/return-to-latest-pin.test.tsx | 2 - .../transcript-history-notice.test.tsx | 62 ------------ .../transcript-row-projection.test.ts | 95 ------------------- packages/ui/src/chat-view.tsx | 86 +---------------- packages/ui/src/conversation-copy.ts | 24 ----- packages/ui/src/transcript-row-projection.ts | 57 ----------- packages/ui/src/use-chat-scroll.ts | 6 ++ 19 files changed, 71 insertions(+), 561 deletions(-) delete mode 100644 packages/ui/src/__tests__/transcript-history-notice.test.tsx delete mode 100644 packages/ui/src/__tests__/transcript-row-projection.test.ts delete mode 100644 packages/ui/src/transcript-row-projection.ts diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 54e90c26b9..b46d20963b 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -24,19 +24,18 @@ const TURN = '.maka-transcript-turn'; /** Turns the partial-history fixture seeds. */ const PARTIAL_HISTORY_TURN_COUNT = 18; -test('bounded transcript ranges expose only their truthful boundary gaps', async ({ +test('a bounded transcript range reaches its whole history without a control to ask', async ({ partialHistoryWindow: page, }) => { await page.setViewportSize({ width: 1_400, height: 800 }); - const olderGap = page.locator('[data-transcript-gap="older"]'); - const newerGap = page.locator('[data-transcript-gap="newer"]'); - await expect(olderGap).toBeVisible(); - await expect(olderGap.getByRole('button', { - name: /^(?:加载较早消息|Load earlier messages)$/, - })).toBeVisible(); - await expect(newerGap).toHaveCount(0); + // The window is bounded, and its boundaries are not something the reader is + // shown or has to act on: the transcript renders Turns and nothing else. + await expect(page.locator(TURN).first()).toBeVisible(); + await expect(page.locator(GAP)).toHaveCount(0); + await expect(page.locator('[data-transcript-gap]')).toHaveCount(0); await expect(page.locator('.maka-transcript-history-controls')).toHaveCount(0); + expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); const oldestPrompt = page.locator( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', @@ -44,42 +43,24 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async await expect(oldestPrompt).toBeVisible(); await oldestPrompt.click(); - const firstTurn = page.locator('[data-turn-id="turn-partial-history-1"]'); - await expect(firstTurn).toBeVisible(); + await expect(page.locator('[data-turn-id="turn-partial-history-1"]')).toBeVisible(); // Where the jump landed, read from the reading position rather than from // `data-search-highlight`: that highlight clears itself 2.2s after the // command lands, so waiting for the Turn to mount and then asserting it // fails whenever loading the page around it takes longer than the flash — // measured here as a 3s pass turning into an 18s timeout under load. await expect(oldestPrompt).toHaveAttribute('data-active', 'true'); - await expect(olderGap).toHaveCount(0); - await expect(newerGap).toBeVisible(); - await expect(newerGap.getByRole('button', { - name: /^(?:加载较新消息|Load newer messages)$/, - })).toBeVisible(); - await expect(page.locator(GAP)).toHaveCount(1); // A jump lands on its own page, not on the whole history. expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + await expect(page.locator('[data-transcript-gap]')).toHaveCount(0); - const loadNewer = newerGap.getByRole('button', { - name: /^(?:加载较新消息|Load newer messages)$/, - }); - await loadNewer.click(); - await expect(page.locator('[data-turn-id="turn-partial-history-2"]')).toBeVisible(); - await expect(olderGap).toHaveCount(0); - await expect(newerGap).toBeVisible(); - await expect(loadNewer).toBeEnabled(); - - await loadNewer.click(); - await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible(); - // Paging newer used to push the oldest Turn out of a Host-bounded range and - // put an older gap back. The Renderer owns the window now and keeps what the - // reader can still reach, so the only truthful boundary is still the newer one. - await expect(olderGap).toHaveCount(0); - await expect(newerGap).toBeVisible(); - await expect(page.locator(GAP)).toHaveCount(1); - await expect(loadNewer).toBeEnabled(); - await expect(oldestPrompt).toBeVisible(); + // The newer side of the jump fills on its own as the reader moves into it. + await page.mouse.move(700, 400); + await expect(async () => { + await page.mouse.wheel(0, 400); + await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible(); + }).toPass({ timeout: 30_000 }); + await expect(page.locator('[data-transcript-gap]')).toHaveCount(0); const returnToLatest = page.getByRole('button', { name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, @@ -91,7 +72,6 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async // the paging above, and measured past the suite's 10s expect timeout here. await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)) .toBeVisible({ timeout: 30_000 }); - await expect(newerGap).toHaveCount(0); await expect(oldestPrompt).toBeVisible(); expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); }); diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 2f22685add..297b706854 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -29,7 +29,6 @@ import { createAppShellSessionUiStateController, TranscriptReadingPositionController, type TranscriptReadingPositionCommands, - type TranscriptHistoryPending, } from '../../renderer/features/conversation/index.js'; import { createTranscriptRestoreLifecycle, @@ -139,60 +138,40 @@ test('an overlay-only bookmark stays available without loading another range', a } }); -test('a history load holds its pending state until the page settles', async () => { - const fixture = controllerFixture(); - const older = deferred(); - const calls: string[] = []; - fixture.controller.loadBefore = async () => { calls.push('older'); await older.promise; }; - await fixture.render(); - - const loading = fixture.commands.current!.loadHistory('earlier'); - assert.deepEqual(calls, ['older']); - assert.equal(fixture.pending(), 'session-1'); - older.resolve(); - await loading; - assert.equal(fixture.pending(), undefined); -}); - -test('a failed history load reports to its own Session and clears its pending state', async () => { +test('a failed return to the tail reports to its own Session', async () => { const fixture = controllerFixture(); const errors: string[] = []; fixture.props.onNavigationError = (error) => { errors.push(String(error)); }; - fixture.controller.loadAfter = async () => { throw new Error('later read failed'); }; + fixture.controller.loadLatest = async () => { throw new Error('tail read failed'); }; await fixture.render(); - await fixture.commands.current!.loadHistory('later'); - assert.deepEqual(errors, ['Error: later read failed']); - assert.equal(fixture.pending(), undefined); + await fixture.commands.current!.returnToLatest(); + assert.deepEqual(errors, ['Error: tail read failed']); }); -test('an old Session history load cannot report or clear the new Session state', async () => { +test('an old Session return to the tail cannot report against the new Session', async () => { const fixture = controllerFixture(); const first = deferred(); fixture.props.onNavigationError = () => assert.fail('a superseded Session must not report'); - fixture.controller.loadBefore = () => first.promise; + fixture.controller.loadLatest = () => first.promise; await fixture.render(); - const loadingFirst = fixture.commands.current!.loadHistory('earlier'); - assert.equal(fixture.pending(), 'session-1'); + const returningFirst = fixture.commands.current!.returnToLatest(); fixture.props.currentSessionId.current = 'session-2'; fixture.props.sessionId = 'session-2'; fixture.props.rangeController.current = { ...fixture.controller, store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, - loadBefore: async () => {}, + loadLatest: async () => {}, }; await fixture.render(); - const loadingSecond = fixture.commands.current!.loadHistory('earlier'); - await loadingSecond; - assert.equal(fixture.pending(), undefined); + await fixture.commands.current!.returnToLatest(); - first.reject(new Error('superseded history request failed')); - await loadingFirst; - assert.equal(fixture.pending(), undefined); + first.reject(new Error('superseded tail request failed')); + await returningFirst; }); -test('filling an edge leaves an outstanding jump and its pending state alone', async () => { +test('filling an edge leaves an outstanding jump alone', async () => { const fixture = controllerFixture(); let cleared = 0; fixture.props.searchTarget = { sessionId: 'session-1', nonce: 1, turnId: 'turn-1' } as never; @@ -205,7 +184,6 @@ test('filling an edge leaves an outstanding jump and its pending state alone', a // towards decides nothing, so it must not answer for the reader. await assert.rejects(fixture.commands.current!.prefetchHistory('older'), failure); assert.equal(cleared, 0); - assert.equal(fixture.pending(), undefined); }); test('retaining the reader window trims the store to the visible Turns', async () => { @@ -240,7 +218,6 @@ function controllerFixture() { snapshot: () => ({ messages: [] }), }, }; - let pending: TranscriptHistoryPending | undefined; const props: ComponentProps = { commands, sessionId: 'session-1', @@ -253,12 +230,11 @@ function controllerFixture() { turnIndex: undefined, setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - setHistoryPending: (next) => { pending = typeof next === 'function' ? next(pending) : next; }, onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), }; return { - commands, controller, props, pending: () => pending?.sessionId, + commands, controller, props, render: () => act(() => root.render(createElement(TranscriptReadingPositionController, props))), }; } diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index 883f6eba64..f8729f7117 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -35,7 +35,6 @@ import { createAppShellSessionUiStateController, TranscriptReadingPositionController, type TranscriptReadingPositionCommands, - type TranscriptHistoryPending, } from '../../renderer/features/conversation/index.js'; const cleanups: Array<() => Promise> = []; @@ -99,15 +98,15 @@ test('a send is accepted before latest history loads and its old completion cann assert.equal(fixture.scroller.scrollTop, 900); }); -for (const direction of ['earlier', 'later'] as const) { - test(`${direction} history navigation supersedes the background range load of an accepted send`, async () => { +for (const direction of ['older', 'newer'] as const) { + test(`filling the ${direction} edge supersedes the background range load of an accepted send`, async () => { const fixture = viewportFixture(); const latest = deferred(); fixture.controller.loadLatest = () => latest.promise; await fixture.render(); await fixture.readAt(1000); await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - await fixture.activateHistoryGap(direction); + await fixture.fillEdge(direction); const readerTop = fixture.scroller.scrollTop; await act(async () => { latest.resolve(); }); @@ -265,14 +264,11 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { searchTarget: undefined, clearSearchTarget: () => {}, turnIndex: undefined, setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - setHistoryPending: () => {}, onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), }; let authority: TranscriptScrollAuthority | undefined; function Harness() { const scrollRef = useRef(scroller); - const [, setHistoryPending] = useState(); - props.setHistoryPending = setHistoryPending; authority = useTranscriptScrollAuthority(); const anchor = sessionUi.transcriptReadingAnchorBySessionRef.current[props.sessionId!]; useChatScroll({ @@ -283,7 +279,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { return createElement(Fragment, null, createElement(TranscriptReadingPositionController, props), options.returnButton ? createElement(TranscriptScrollButton, { - onActivate: () => commands.current?.loadHistory('latest'), + onActivate: () => commands.current?.returnToLatest(), }) : null, ); } @@ -299,9 +295,10 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { assert.ok(button); await act(() => { button.dispatchEvent(new window.Event('click', { bubbles: true })); }); }, - async activateHistoryGap(direction: 'earlier' | 'later') { - // ChatView releases the pin before invoking either history gap action. - await act(async () => { authority!.releasePin(); await commands.current!.loadHistory(direction); }); + async fillEdge(edge: 'older' | 'newer') { + // A reader who scrolls to an edge releases the pin, and the band fills + // that edge behind them. + await act(async () => { authority!.releasePin(); await commands.current!.prefetchHistory(edge); }); }, async readAt(offset: number) { await act(() => { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1117568717..ffbcebe068 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -71,7 +71,6 @@ import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; import * as Conversation from './features/conversation'; -import type { TranscriptHistoryPending } from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; @@ -416,7 +415,6 @@ function AppShellContent({ const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); - const [historyLoadPending, setHistoryLoadPending] = useState(); const transcriptReadingCommands = useRef(null); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -2332,7 +2330,6 @@ function AppShellContent({ turnIndex={transcriptTurnIndex} setTurnIndex={setTranscriptTurnIndex} listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} - setHistoryPending={setHistoryLoadPending} onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale), @@ -2492,7 +2489,7 @@ function AppShellContent({ desktopConversationCopy.actions.scrollMainToBottom } onReturnToTail={activeTranscriptRange?.hasNewer - ? () => transcriptReadingCommands.current?.loadHistory('latest') + ? () => transcriptReadingCommands.current?.returnToLatest() : undefined} hidden={workHubActive || navSelection.section !== 'sessions'} composer={ @@ -2688,8 +2685,6 @@ function AppShellContent({ activeSessionId={activeId} hasOlderHistory={activeTranscriptRange?.hasOlder} hasNewerHistory={activeTranscriptRange?.hasNewer} - historyLoadPending={historyLoadPending} - onLoadHistory={(target) => transcriptReadingCommands.current?.loadHistory(target)} onPrefetchHistory={(edge) => transcriptReadingCommands.current?.prefetchHistory(edge) ?? Promise.resolve()} onRetainWindow={(window) => transcriptReadingCommands.current?.retainWindow(window)} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index f2a200293f..bb8eb45fff 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -39,7 +39,6 @@ import { selectLiveTurn } from './use-app-shell-session-ui-reads'; import { useExternalStoreSelector } from './use-external-store-selector'; import { useDeepResearchRun } from './use-deep-research-run'; import { ChatRecoveryNotice, SessionHealthRecoveryNotice } from './chat-recovery-notice'; -import type { TranscriptHistoryPending } from './features/conversation'; const selectShellRunRecord = (state: AppShellSessionUiState, sessionId: string | undefined) => sessionId ? state.shellRunUpdatesBySession[sessionId] : undefined; @@ -63,7 +62,6 @@ interface ChatMessageSurfaceProps extends Omit< | 'liveTurn' | 'shellRunUpdates' | 'goalIndicator' - | 'historyLoadPending' > { /** * #1985: the live projection and the shell-run records are the only session @@ -93,8 +91,6 @@ interface ChatMessageSurfaceProps extends Omit< onSkip: () => Promise | void; hasOlderHistory?: boolean; hasNewerHistory?: boolean; - historyLoadPending?: TranscriptHistoryPending; - onLoadHistory: (target: 'earlier' | 'later' | 'latest') => Promise | void; onPrefetchHistory: (edge: 'older' | 'newer') => Promise; onRetainWindow: (window: { firstTurnId: string; lastTurnId: string }) => void; } @@ -131,8 +127,6 @@ export function ChatMessageSurface({ onSkip, hasOlderHistory, hasNewerHistory, - historyLoadPending, - onLoadHistory, onPrefetchHistory, onRetainWindow, ...chatViewRest @@ -253,11 +247,6 @@ export function ChatMessageSurface({ goalIndicator={goalProjection.goalIndicator} hasOlderHistory={hasOlderHistory} hasNewerHistory={hasNewerHistory} - historyLoadPending={historyLoadPending && historyLoadPending.sessionId === activeSessionId - ? historyLoadPending.target === 'earlier' ? 'older' : 'newer' - : undefined} - onLoadEarlierHistory={() => onLoadHistory('earlier')} - onLoadLaterHistory={() => onLoadHistory('later')} onPrefetchHistory={onPrefetchHistory} onRetainWindow={onRetainWindow} /> diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx index 951a5637a8..70ab3cf452 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -28,8 +28,6 @@ import { prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryPending, - type TranscriptHistoryTarget, } from './transcript-reading-position.js'; type RangeController = NonNullable>[0]['controller']> & { @@ -51,7 +49,7 @@ interface TurnIndex { export interface TranscriptReadingPositionCommands { prepareSend(sessionId: string): Promise; captureAnchor(turnId?: string): void; - loadHistory(target: TranscriptHistoryTarget): Promise; + returnToLatest(): Promise; prefetchHistory(edge: 'older' | 'newer'): Promise; retainWindow(window: { firstTurnId: string; lastTurnId: string }): void; } @@ -71,7 +69,6 @@ export function TranscriptReadingPositionController(props: { turnIndex: TurnIndex | undefined; setTurnIndex: Dispatch>; listTurnLandmarks: Parameters>[0]['list']; - setHistoryPending: Dispatch>; onRestoreError(error: unknown, sessionId: string): void; onNavigationError(error: unknown, sessionId: string): void; }) { @@ -81,9 +78,6 @@ export function TranscriptReadingPositionController(props: { >(undefined); const isCurrent = (sessionId: string, controller: object) => props.currentSessionId.current === sessionId && props.rangeController.current === controller; - const cancelHistory = (sessionId: string) => { - props.setHistoryPending((current) => current?.sessionId === sessionId ? undefined : current); - }; const cancel = (sessionId: string, clearAnchor = false) => { lifecycle.cancel(sessionId); if (props.searchTarget?.sessionId === sessionId) props.clearSearchTarget(); @@ -94,7 +88,6 @@ export function TranscriptReadingPositionController(props: { }; useImperativeHandle(props.commands, () => ({ prepareSend(sessionId) { - cancelHistory(sessionId); return prepareTranscriptForSend({ sessionId, currentSessionId: props.currentSessionId, controller: props.rangeController, cancel, @@ -127,10 +120,11 @@ export function TranscriptReadingPositionController(props: { }, /** * Fills the window at an edge the reader is approaching. Deliberately not - * `loadHistory`: that one cancels restoration and clears the search target, - * because a reader who asks for history has decided where to be. Filling - * decides nothing, so it must leave an outstanding jump alone — the page it - * is waiting for can still be in flight — and it answers whether the window + * `returnToLatest`: that one cancels restoration and clears the search + * target, because a reader who asks to go somewhere has decided where to + * be. Filling decides nothing, so it must leave an outstanding jump alone — + * the page it is waiting for can still be in flight — and it answers + * whether the window * moved, so its caller can tell a filled window from a read that failed or * was refused as stale and would fail again unchanged. */ @@ -143,21 +137,15 @@ export function TranscriptReadingPositionController(props: { else await controller.loadAfter(); return controller.store.snapshot() !== before; }, - async loadHistory(target) { + async returnToLatest() { const controller = props.rangeController.current; const { sessionId } = props; if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - cancel(sessionId, target === 'latest'); - props.setHistoryPending({ sessionId, target }); + cancel(sessionId, true); try { - if (target === 'latest') await controller.loadLatest(); - else if (target === 'earlier') await controller.loadBefore(); - else await controller.loadAfter(); + await controller.loadLatest(); } catch (error) { if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); - } finally { - props.setHistoryPending((current) => - current?.sessionId === sessionId && current.target === target ? undefined : current); } }, })); @@ -177,11 +165,6 @@ export function TranscriptReadingPositionController(props: { useEffect(() => () => { lifecycle.deactivate(); }, [props.sessionId, props.profileId, lifecycle]); - useEffect(() => { - if (props.searchTarget) { - cancelHistory(props.searchTarget.sessionId); - } - }, [props.searchTarget?.nonce]); useEffect(() => restoreSessionTranscriptRange({ lifecycle, sessionId: props.sessionId, diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 5b00d1fb0f..e691b8ee82 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -217,13 +217,6 @@ export function refreshTranscriptTurnLandmarks(options: { }; } -export type TranscriptHistoryTarget = 'earlier' | 'later' | 'latest'; - -export interface TranscriptHistoryPending { - readonly sessionId: string; - readonly target: TranscriptHistoryTarget; -} - export function restoreSessionTranscriptRange(options: { readonly lifecycle: TranscriptRestoreLifecycle; readonly sessionId?: string; diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 141ad9ddc0..552d716dc2 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -27,9 +27,6 @@ export const transcriptReadingPosition = { restoreTarget: transcriptRestoreTarget, }; -export type { - TranscriptHistoryPending, -} from './controller/transcript-reading-position.js'; export { TranscriptReadingPositionController, type TranscriptReadingPositionCommands, diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index b96d07a488..fa076070f5 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -22,5 +22,4 @@ export { prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryPending, } from './controller/transcript-reading-position.js'; diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 0fa66eb5bc..ece9ceffba 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -54,9 +54,8 @@ /* These rows mount and unmount around the reader without being anything the reader is reading, so anchoring on one moves the transcript for no content: - a boundary gap, a send that is not durable yet, the placeholder held until - the live Turn exists. */ -.maka-transcript-gap-row, + a send that is not durable yet, the placeholder held until the live Turn + exists. */ [data-transient-message-id], .maka-turn[data-live-streaming='true']:not([data-turn-id]) { overflow-anchor: none; @@ -388,8 +387,3 @@ color: var(--foreground); padding: var(--space-0-5) var(--space-1-5); } -.maka-transcript-gap-row { - width: min(var(--maka-reading-measure), 100%); - margin: var(--space-2) auto; - padding-block: var(--space-1); -} diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 25df5dc9d1..f175e54e4f 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1972,50 +1972,22 @@ function PartialHistoryHarness() { onLoadTranscriptTurn: () => setReadingEarlier(true), hasOlderHistory: !readingEarlier, hasNewerHistory: readingEarlier, - onLoadLaterHistory: () => setReadingEarlier(false), + onPrefetchHistory: async (edge) => { + if (edge === 'newer') setReadingEarlier(false); + return true; + }, }} /> ); } -function historyGapPresentation(gap: HTMLElement) { - const style = getComputedStyle(gap); - const box = gap.getBoundingClientRect(); - const composer = document.querySelector('.maka-composer-astryx'); - const frame = gap.closest('.appFrame'); - if (!composer || !frame) throw new Error('The shell geometry is incomplete'); - const composerBox = composer.getBoundingClientRect(); - const frameBox = frame.getBoundingClientRect(); - return { - backgroundColor: style.backgroundColor, - borderWidths: [ - style.borderTopWidth, - style.borderRightWidth, - style.borderBottomWidth, - style.borderLeftWidth, - ], - display: style.display, - flexWrap: style.flexWrap, - justifyContent: style.justifyContent, - widthDelta: Math.abs(box.width - composerBox.width), - centerDelta: Math.abs( - (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, - ), - fitsFrame: box.left >= frameBox.left && box.right <= frameBox.right, - clientWidth: gap.clientWidth, - scrollWidth: gap.scrollWidth, - hasHorizontalOverflow: gap.scrollWidth > gap.clientWidth, - }; -} -// Real path: selecting a prompt outside the loaded transcript range, then -// loading the newer range. Each boundary stays a quiet reading-column -// control and every inactive prompt-rail tick uses one neutral treatment. +// Real path: selecting a prompt outside the loaded transcript range. The +// transcript shows Turns and nothing else — a range boundary is not a thing to +// read — and every inactive prompt-rail tick uses one neutral treatment. export const PartialHistoryNotice: Story = { render: () => , play: async ({ canvasElement }) => { - expect(canvasElement.querySelector('[data-transcript-gap="older"]')).not.toBeNull(); - expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).toBeNull(); const firstPrompt = canvasElement.querySelector( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-scroll-1"]', ); @@ -2023,22 +1995,9 @@ export const PartialHistoryNotice: Story = { firstPrompt.click(); await waitFor(() => { - expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).not.toBeNull(); + expect(canvasElement.querySelector('[data-turn-id="turn-scroll-1"]')).not.toBeNull(); }); - const gap = canvasElement.querySelector('[data-transcript-gap="newer"]'); - if (!gap) throw new Error('The newer transcript gap did not render'); - expect(gap.textContent).toContain('下方还有未加载的较新消息'); - expect(gap.textContent).toContain('加载较新消息'); - - const regular = historyGapPresentation(gap); - expect(regular.backgroundColor).toBe('rgba(0, 0, 0, 0)'); - expect(regular.borderWidths).toEqual(['0px', '0px', '0px', '0px']); - expect(regular.display).toBe('flex'); - expect(regular.flexWrap).toBe('wrap'); - expect(regular.justifyContent).toBe('center'); - expect(regular.widthDelta).toBeLessThanOrEqual(1); - expect(regular.centerDelta).toBeLessThanOrEqual(1); - expect(regular.hasHorizontalOverflow, JSON.stringify(regular)).toBe(false); + expect(canvasElement.querySelectorAll('[data-transcript-gap]')).toHaveLength(0); const neutralPaint = [ ...canvasElement.querySelectorAll('.maka-prompt-rail-tick'), @@ -2063,21 +2022,6 @@ export const PartialHistoryNotice: Story = { [...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident'))), ).toHaveLength(0); - const frame = canvasElement.querySelector('.appFrame'); - if (!frame) throw new Error('Shell frame did not render'); - frame.style.width = '520px'; - await painted(2); - const narrow = historyGapPresentation(gap); - expect(narrow.centerDelta).toBeLessThanOrEqual(1); - expect(narrow.fitsFrame).toBe(true); - expect(narrow.hasHorizontalOverflow, JSON.stringify(narrow)).toBe(false); - - const loadNewerButton = within(gap).getByRole('button', { name: '加载较新消息' }); - loadNewerButton.click(); - await waitFor(() => { - expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).toBeNull(); - expect(canvasElement.querySelector('[data-turn-id="turn-scroll-8"]')).not.toBeNull(); - }); }, }; @@ -2285,14 +2229,12 @@ export const SubmittedPromptSettlesWithoutReversing: Story = { /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; -/** Every `onLoadEarlierHistory` the transcript asked for, oldest resident turn first. */ +/** Every older fill the transcript asked for, oldest resident turn first. */ const historyLoads: string[] = []; const HISTORY_BATCH = 4; -// More than any story here consumes. Running the history out retires the -// "earlier history" notice, and that removal is a height change above the -// reader with no arrival to explain it. +// More than any story here consumes, so no story reaches the end of history. const HISTORY_BATCHES_AVAILABLE = 8; /** A settled transcript with a turn the play function can make arrive. */ @@ -2330,13 +2272,15 @@ function HistoryHarness({ turns }: { turns: number }) { chat={{ messages: transcriptTurns(range.from, range.count), hasOlderHistory: range.from > -HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, - onLoadEarlierHistory: async () => { + onPrefetchHistory: async (edge) => { + if (edge !== 'older') return false; historyLoads.push(firstResidentTurnId() ?? '(none)'); setRange((current) => ({ from: current.from - HISTORY_BATCH, count: current.count + HISTORY_BATCH, })); await painted(2); + return true; }, }} /> diff --git a/packages/ui/src/__tests__/conversation-copy.test.ts b/packages/ui/src/__tests__/conversation-copy.test.ts index 9ee443a079..11dc7c6f2c 100644 --- a/packages/ui/src/__tests__/conversation-copy.test.ts +++ b/packages/ui/src/__tests__/conversation-copy.test.ts @@ -37,27 +37,6 @@ test('explains why folder-reference messages cannot be edited and resent', () => ); }); -test('labels incomplete transcript boundaries without inventing missing Turn counts', () => { - assert.deepEqual(getConversationCopy('zh-CN').chat.transcriptGap, { - olderDescription: '上方还有未加载的较早消息', - olderAction: '加载较早消息', - newerDescription: '下方还有未加载的较新消息', - newerAction: '加载较新消息', - }); - assert.deepEqual(getConversationCopy('zh-TW').chat.transcriptGap, { - olderDescription: '上方還有未載入的較早訊息', - olderAction: '載入較早訊息', - newerDescription: '下方還有未載入的較新訊息', - newerAction: '載入較新訊息', - }); - assert.deepEqual(getConversationCopy('en').chat.transcriptGap, { - olderDescription: 'Earlier messages above are not loaded.', - olderAction: 'Load earlier messages', - newerDescription: 'Newer messages below are not loaded.', - newerAction: 'Load newer messages', - }); -}); - test('context usage explains missing data without exposing provider internals', () => { assert.equal( getConversationCopy('zh-CN').messages.systemNotes.contextUsageUnavailable, diff --git a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx index a7e95eb5b3..2a18d4600a 100644 --- a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx +++ b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx @@ -197,8 +197,6 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret scrollBehavior: 'auto' as const, hasOlderHistory: true, hasNewerHistory: true, - onLoadEarlierHistory: () => undefined, - onLoadLaterHistory: () => undefined, onReadingAnchorChange: (turnId?: string) => { anchors.push(turnId); }, diff --git a/packages/ui/src/__tests__/transcript-history-notice.test.tsx b/packages/ui/src/__tests__/transcript-history-notice.test.tsx deleted file mode 100644 index 13d67ad977..0000000000 --- a/packages/ui/src/__tests__/transcript-history-notice.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { TranscriptHistoryGapRow } from '../chat-view.js'; - -function renderGap( - direction: 'older' | 'newer', - isPending: boolean, -): string { - return renderToStaticMarkup( - undefined} - />, - ); -} - -test('presents an older boundary gap as an in-flow transcript row', () => { - const markup = renderGap('older', false); - - assert.match(markup, /role="status"/); - assert.match(markup, /aria-live="polite"/); - assert.match(markup, /aria-atomic="true"/); - assert.match(markup, /data-transcript-gap="older"/); - assert.match(markup, /maka-transcript-gap-row/); - assert.match(markup, /Earlier messages are not loaded/); - assert.match(markup, /Load earlier messages/); - assert.doesNotMatch(markup, / { - const markup = renderGap('newer', true); - - assert.match(markup, /data-transcript-gap="newer"/); - assert.match(markup, /Newer messages are not loaded/); - assert.match(markup, /Load newer messages/); - assert.doesNotMatch(markup, /disabled/); - assert.match(markup, /aria-busy="true"/); -}); diff --git a/packages/ui/src/__tests__/transcript-row-projection.test.ts b/packages/ui/src/__tests__/transcript-row-projection.test.ts deleted file mode 100644 index 495252fa89..0000000000 --- a/packages/ui/src/__tests__/transcript-row-projection.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { projectTranscriptRows } from '../transcript-row-projection.js'; - -interface TurnStub { - turnId: string; -} - -const turns: readonly TurnStub[] = [ - { turnId: 'turn-2' }, - { turnId: 'turn-3' }, - { turnId: 'turn-4' }, -]; - -function rowKeys(input: ReturnType>): string[] { - return input.map((row) => row.kind === 'turn' ? row.turn.turnId : `gap:${row.direction}`); -} - -describe('transcript boundary row projection', () => { - test('preserves the resident Turn order when both boundaries are complete', () => { - const rows = projectTranscriptRows({ turns, hasOlder: false, hasNewer: false }); - - assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4']); - assert.strictEqual(rows[0]?.kind === 'turn' ? rows[0].turn : undefined, turns[0]); - }); - - test('places one older gap before the resident window', () => { - const rows = projectTranscriptRows({ turns, hasOlder: true, hasNewer: false }); - - assert.deepEqual(rowKeys(rows), ['gap:older', 'turn-2', 'turn-3', 'turn-4']); - }); - - test('places one newer gap after a resident window without an active Turn', () => { - const rows = projectTranscriptRows({ turns, hasOlder: false, hasNewer: true }); - - assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4', 'gap:newer']); - }); - - test('places the newer gap immediately before the separately rendered active Turn', () => { - const rows = projectTranscriptRows({ - turns, - hasOlder: true, - hasNewer: true, - activeTurnId: 'turn-4', - }); - - assert.deepEqual(rowKeys(rows), [ - 'gap:older', - 'turn-2', - 'turn-3', - 'gap:newer', - 'turn-4', - ]); - }); - - test('keeps a newer gap at the trailing boundary when the active Turn is not resident', () => { - const rows = projectTranscriptRows({ - turns, - hasOlder: false, - hasNewer: true, - activeTurnId: 'turn-live', - }); - - assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4', 'gap:newer']); - }); - - test('projects only the two truthful boundaries for an empty resident window', () => { - const rows = projectTranscriptRows({ - turns: [], - hasOlder: true, - hasNewer: true, - }); - - assert.deepEqual(rowKeys(rows), ['gap:older', 'gap:newer']); - }); -}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 6c03a5b11f..84627edaf7 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -61,7 +61,6 @@ import { useChatScroll } from './use-chat-scroll.js'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; -import { projectTranscriptRows } from './transcript-row-projection.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { SessionContextLayer, type SessionContextGoal } from './session-context-layer.js'; @@ -75,16 +74,6 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } -export type TranscriptHistoryLoadDirection = 'older' | 'newer'; - -export interface TranscriptHistoryGapRowProps { - direction: TranscriptHistoryLoadDirection; - description: string; - actionLabel: string; - isPending: boolean; - onActivate(): Promise | void; -} - export interface ChatViewGoalIndicatorProps { /** * Active autonomous-goal indicator for the session, or undefined when no @@ -126,41 +115,6 @@ export function resolveRailAlignedTarget - {description} -