From 846887c0320c46237be4501b46eb289d2aa7b5af Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 30 Aug 2026 00:19:10 +0800 Subject: [PATCH 01/13] perf(desktop): bound active transcript rendering Make Runtime Host own the bounded Turn range, keep immutable stable Renderer projections, and delegate offscreen rendering to Chromium content visibility. Delete the Renderer row virtualizer, height index, spacers, and their compensation state. Generated-by: Codex --- apps/desktop/e2e/prompt-rail.spec.ts | 168 ++++---- apps/desktop/e2e/transcript-scroll.spec.ts | 85 ++--- .../desktop-transcript-range-store.test.ts | 217 ++++++++++- .../main/__tests__/streaming-handoff.test.ts | 4 +- .../src/main/desktop-transcript-replica.ts | 88 ++++- .../src/main/e2e-fixture/seed-helpers.ts | 2 +- .../src/preload/transcript-contract.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 7 +- .../src/renderer/chat-message-surface.tsx | 2 +- .../desktop-transcript-range-store.ts | 119 ++++-- .../src/renderer/styles/chat-message.css | 5 +- .../src/__tests__/prompt-anchor-rail.test.ts | 2 +- .../src/__tests__/turn-height-index.test.ts | 44 --- .../ui/src/__tests__/turn-virtualizer.test.ts | 208 ---------- .../__tests__/use-turn-virtualizer.test.tsx | 134 ------- packages/ui/src/chat-surface-layout.tsx | 2 +- packages/ui/src/chat-view.tsx | 46 +-- packages/ui/src/prompt-anchor-rail.tsx | 25 +- packages/ui/src/turn-height-index.ts | 87 ----- packages/ui/src/turn-virtualizer.ts | 353 ----------------- packages/ui/src/use-chat-scroll.ts | 8 +- packages/ui/src/use-turn-virtualizer.ts | 358 ------------------ 22 files changed, 549 insertions(+), 1417 deletions(-) delete mode 100644 packages/ui/src/__tests__/turn-height-index.test.ts delete mode 100644 packages/ui/src/__tests__/turn-virtualizer.test.ts delete mode 100644 packages/ui/src/__tests__/use-turn-virtualizer.test.tsx delete mode 100644 packages/ui/src/turn-height-index.ts delete mode 100644 packages/ui/src/turn-virtualizer.ts delete mode 100644 packages/ui/src/use-turn-virtualizer.ts diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 9596088c71..f405040d78 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -18,7 +18,8 @@ */ import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import { expect, test } from './fixtures'; +import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; +import { ensureSidebarExpanded, expect, test } from './fixtures'; import type { Page } from '@playwright/test'; const MAX_PROMPT_RAIL_TICKS = 64; @@ -95,6 +96,20 @@ async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promi await waitForPaintedFrames(page); } +async function scrollTranscriptAwayFromTail(page: Page): Promise { + await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + if (!root) throw new Error('the chat scroll container is missing'); + const historyLoadBand = Math.max(640, root.clientHeight * 2); + root.scrollTop = Math.min( + root.scrollHeight - root.clientHeight - 100, + historyLoadBand + 200, + ); + root.dispatchEvent(new Event('scroll')); + }); + await waitForPaintedFrames(page); +} + async function waitForPaintedFrames(page: Page, count = 2): Promise { await page.evaluate((frames) => new Promise((resolve) => { const tick = (left: number) => { @@ -116,15 +131,6 @@ function notifyTranscriptScrolled(page: Page): Promise { }); } -async function loadPromptRailBeyondVirtualWindow(page: Page): Promise { - const transcript = page.locator('.maka-chat-message-list'); - await scrollTranscriptTo(page, 'top'); - await transcript.hover(); - await page.mouse.wheel(0, -100); - await expect.poll(async () => Number(await transcript.getAttribute('data-turn-source-count'))) - .toBeGreaterThan(100); -} - test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => { // Measured over ALL ticks, not a sample: a helper that skips what it cannot // evaluate creates its blind spot exactly where a regression lives. @@ -262,43 +268,37 @@ test('the first click of a session lands on its prompt and holds', async ({ expect(settled?.tickIsCurrent).toBe(true); }); -test('long transcripts keep a bounded mounted turn window', async ({ +test('active transcript Turns keep stable DOM identities while scrolling', async ({ promptRailWindow: page, }) => { - const count = async () => page.locator('[data-virtual-turn-id]').count(); - await page.locator('[data-virtual-turn-id]').first().waitFor(); - await loadPromptRailBeyondVirtualWindow(page); - expect(await page.evaluate(() => { - const transcript = document.querySelector('.maka-chat-message-list'); - const rows = transcript?.firstElementChild; - const turn = document.querySelector('[data-virtual-turn-id]'); - if (!rows || !turn) throw new Error('the virtual transcript is missing'); - return { - list: Number.parseFloat(getComputedStyle(rows).rowGap), - turn: Number.parseFloat(getComputedStyle(turn).rowGap), - }; - })).toEqual({ list: 16, turn: 16 }); - expect(await count()).toBeGreaterThan(0); - expect(await count()).toBeLessThanOrEqual(100); - await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); - await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); - expect(await count()).toBeGreaterThan(0); - expect(await count()).toBeLessThanOrEqual(100); + const sourceCount = Number( + await page.locator('.maka-chat-message-list').getAttribute('data-turn-source-count'), + ); + expect(sourceCount).toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); + await page.evaluate(() => { + for (const turn of document.querySelectorAll('[data-turn-id]')) { + turn.dataset.stableMountProbe = turn.dataset.turnId; + } + }); + + await scrollTranscriptTo(page, 'bottom'); + await scrollTranscriptAwayFromTail(page); + + expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); + expect(await page.locator('[data-turn-id][data-stable-mount-probe]').count()).toBe(sourceCount); }); -test('evicting a turn-owned sibling interaction hands focus back to the transcript', async ({ +test('scrolling away preserves a turn-owned focus and selection', async ({ promptRailWindow: page, }) => { - const scroller = page.locator('[data-chat-scroll-container="true"]'); - await page.locator('[data-virtual-turn-id]').first().waitFor(); - await loadPromptRailBeyondVirtualWindow(page); await scrollTranscriptTo(page, 'bottom'); - await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); - const retainedTurnId = await page.evaluate(() => { - const turns = document.querySelectorAll('[data-virtual-turn-id]'); - const turn = turns.item(turns.length - 1); - if (!turn?.dataset.virtualTurnId) throw new Error('the mounted turn is missing'); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + await page.evaluate(() => { + const turn = document.querySelector('[data-turn-id="turn-prompt-rail-120"]'); + if (!turn) throw new Error('the tail Turn is missing'); const turnOwnedAction = document.createElement('button'); + turnOwnedAction.dataset.turnOwnedAction = 'true'; turnOwnedAction.textContent = 'Turn-owned action'; turn.append(turnOwnedAction); turnOwnedAction.focus(); @@ -307,41 +307,77 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri const selection = document.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); - return turn.dataset.virtualTurnId; }); + await scrollTranscriptAwayFromTail(page); - // The injected control resizes the tail Turn. That can queue a scroll-anchor - // restore captured at the bottom. Let that setup-only restore finish before - // the one-shot jump so the assertion still catches any later restore that - // would pin the viewport back on the retained Turn (#3121). - await waitForPaintedFrames(page); - await scrollTranscriptTo(page, 'top'); - await notifyTranscriptScrolled(page); - await expect.poll(async () => page.evaluate((turnId) => { - const root = document.querySelector('[data-chat-scroll-container="true"]'); - if (!root) throw new Error('the chat scroll container is missing'); - const mounted = [...document.querySelectorAll('[data-virtual-turn-id]')] - .map((turn) => turn.dataset.virtualTurnId ?? ''); + await expect.poll(async () => page.evaluate(() => { const active = document.activeElement; return { - retained: mounted.includes(turnId), - scrollTop: Math.round(root.scrollTop), - firstMounted: mounted[0] ?? null, - lastMounted: mounted.at(-1) ?? null, - focusOnTranscript: active instanceof HTMLElement - && active.classList.contains('maka-chat-message-list'), - selectionCollapsed: document.getSelection()?.isCollapsed ?? true, + retained: document.querySelector('[data-turn-id="turn-prompt-rail-120"]') !== null, + focusRetained: active instanceof HTMLElement + && active.dataset.turnOwnedAction === 'true', + selectionRetained: document.getSelection()?.isCollapsed === false, }; - }, retainedTurnId), { - message: 'the retained tail turn leaves after one jump to the top', - }).toMatchObject({ - retained: false, - scrollTop: 0, - focusOnTranscript: true, - selectionCollapsed: true, + })).toEqual({ + retained: true, + focusRetained: true, + selectionRetained: true, }); }); +test('offscreen active Turns remain findable and accessible', async ({ + promptRailWindow: page, +}) => { + const firstTurnId = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + const turnNumber = Number(firstTurnId?.split('-').at(-1)); + expect(turnNumber).toBeGreaterThan(0); + const needle = `第 ${turnNumber} 个问题`; + await scrollTranscriptTo(page, 'bottom'); + + const found = await page.evaluate((text) => { + document.getSelection()?.removeAllRanges(); + return (window as Window & { find(text: string): boolean }).find(text); + }, needle); + expect(found).toBe(true); + expect(await page.evaluate(() => document.getSelection()?.toString() ?? '')).toContain(needle); + + const cdp = await page.context().newCDPSession(page); + const tree = await cdp.send('Accessibility.getFullAXTree'); + expect(tree.nodes.some((node) => node.name?.value?.includes(needle))).toBe(true); +}); + +test('switching sessions reconstructs only the Host active range', async ({ + promptRailWindow: page, +}) => { + await ensureSidebarExpanded(page); + const rows = page.locator('.maka-session-row'); + const selected = rows.locator('button.astryx-side-nav-item.selected'); + const originalId = await selected + .evaluate((button) => button.closest('.maka-session-row')?.getAttribute('data-session-id')); + if (!originalId) throw new Error('the prompt-rail Session is not selected'); + const otherId = await rows.evaluateAll( + (rows, selected) => rows + .map((row) => row.getAttribute('data-session-id')) + .find((sessionId) => sessionId !== selected) ?? null, + originalId, + ); + if (!otherId) throw new Error('the fixture has no second Session'); + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button`) + .first() + .click(); + await expect(page.locator( + `.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button.selected`, + )) + .toHaveCount(1); + + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(originalId)}] button`) + .first() + .click(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + expect(await page.locator('[data-turn-id]').count()) + .toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); +}); + test('a tick is what the pointer lands on, not the scrollbar', async ({ promptRailWindow: page, }) => { diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index f8ac41f8c6..1cc005efbf 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -235,7 +235,7 @@ test('content that grows outside the turn wrappers is followed too', async ({ grown.dataset.outsideTurnGrowth = 'true'; grown.style.height = '600px'; list.append(grown); - return grown.closest('[data-virtual-turn-id]') === null; + return grown.closest('[data-transcript-turn-id]') === null; }); await waitForPaintedFrames(page); @@ -374,14 +374,15 @@ test('the dock affordance returns the reader to the tail', async ({ window: page test('earlier history lands above the turn the reader is on', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page.locator('.maka-chat-message-list').getAttribute('data-turn-source-count').then((value) => Number(value)); - const loadedBefore = await loadedTurns(); - - // Just short of the band that asks for more, so the virtual window has - // mounted turns around the reader before the load starts. Landing straight on - // zero puts the viewport inside the leading spacer, where there is no turn to - // be reading and nothing to hold still. + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); + + // Just short of the band that asks for more, so the active range has painted + // turns around the reader before the load starts. Landing straight on zero + // leaves no visible turn above the load boundary to identify as the anchor. await page.evaluate((selector) => { const root = document.querySelector(selector); if (!root) throw new Error('the chat scroll container is missing'); @@ -390,40 +391,44 @@ test('earlier history lands above the turn the reader is on', async ({ await waitForPaintedFrames(page, 6); // The move that asks for earlier history, and the reading of where the - // reader is, in one task: the scroll event that starts the load is dispatched - // afterwards, so the app anchors on the same position this reads. + // reader is, in one task. Keep the reader near the active range's head: an + // anchor near its tail can already have a complete bounded range around it, + // so a valid load has no reason to move the first resident Turn. const anchor = await page.evaluate((selector) => { const root = document.querySelector(selector); if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = Math.max(640, root.clientHeight * 2) - 100; + root.scrollTop = Math.min(300, root.scrollHeight - root.clientHeight); const rootTop = root.getBoundingClientRect().top; const turn = [...root.querySelectorAll('[data-turn-id]')].find( (candidate) => candidate.getBoundingClientRect().bottom > rootTop, ); const turnId = turn?.dataset.turnId; if (!turn || !turnId) throw new Error('no turn is on screen'); - return { turnId, top: Math.round(turn.getBoundingClientRect().top) }; + const anchor = { turnId, top: Math.round(turn.getBoundingClientRect().top) }; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + return anchor; }, SCROLLER); - await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); await waitForPaintedFrames(page); // The turns that arrived went above the reader, and the reader did not go // with them. Asserting the element rather than a `scrollTop` delta is the // point: a compensation computed from `scrollHeight` satisfies the delta // while putting the reader somewhere else entirely. - expect(Math.abs((await turnTop(page, anchor.turnId)) - anchor.top)).toBeLessThanOrEqual(4); + await expect.poll( + async () => Math.abs((await turnTop(page, anchor.turnId)) - anchor.top), + ).toBeLessThanOrEqual(4); }); test('history asked for at the very top of the scroller still lands above the reader', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page - .locator('.maka-chat-message-list') - .getAttribute('data-turn-source-count') - .then((value) => Number(value)); - const loadedBefore = await loadedTurns(); + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); // The one position where the browser declines to anchor, and the one the // wheel-to-load path puts the reader in. @@ -433,7 +438,7 @@ test('history asked for at the very top of the scroller still lands above the re root.scrollTop = 0; }, SCROLLER); - await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); await waitForPaintedFrames(page); // Anchoring resumes at an offset of one pixel, so the offset itself is the @@ -465,12 +470,11 @@ test('history asked for at the very top of the scroller still lands above the re test('following the tail does not ask for the history above it', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page - .locator('.maka-chat-message-list') - .getAttribute('data-turn-source-count') - .then((value) => Number(value)); - const loadedBefore = await loadedTurns(); + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); // Tall enough that the tail sits inside `max(640, clientHeight * 2)`. The // resize itself is a growth signal, so the pin writes the tail and that write @@ -487,20 +491,19 @@ test('following the tail does not ask for the history above it', async ({ // Nothing arrived that the reader did not ask for. await waitForPaintedFrames(page, 12); - expect(await loadedTurns()).toBe(loadedBefore); + expect(await firstLoadedTurn()).toBe(firstBefore); }); -test('a wheel the scroller cannot act on still asks for history', async ({ +test('a wheel a short scroller cannot act on still asks for history', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page - .locator('.maka-chat-message-list') - .getAttribute('data-turn-source-count') - .then((value) => Number(value)); - const loadedBefore = await loadedTurns(); + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); - await page.setViewportSize({ width: 900, height: 1500 }); + await page.setViewportSize({ width: 900, height: 4000 }); await waitForPaintedFrames(page, 6); const asked = await scrollMetrics(page); expect(asked.distance, JSON.stringify(asked)).toBeLessThanOrEqual(4); @@ -515,11 +518,5 @@ test('a wheel the scroller cannot act on still asks for history', async ({ root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); }, SCROLLER); - await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); - - // And it landed above the reader: they are where they were, with more above - // them than before. - const after = await scrollMetrics(page); - expect(after.scrollTop, `${JSON.stringify(asked)} then ${JSON.stringify(after)}`) - .toBeGreaterThan(asked.scrollTop); + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); }); 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 561672602f..641fd33b52 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,6 +26,7 @@ import { encodeDesktopTranscriptSnapshot, } from '../desktop-transcript-ipc.js'; import { + DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, } from '../../preload/transcript-contract.js'; @@ -206,10 +207,123 @@ test('drops stale transcript batches after a generation reset', () => { assert.deepEqual(store.snapshot().messages, [nextMessage]); }); +test('keeps unchanged message references stable across immutable range snapshots', () => { + const identity = { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + }; + const firstMessage = userMessage('first', 'user-1'); + const secondMessage = assistantMessage('second', 'assistant-2'); + const store = transcriptStore(); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, + durableThrough: 1, + durable: [{ sequence: 1, message: firstMessage }], + overlay: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + + const first = store.snapshot(); + assert.strictEqual(store.snapshot(), first); + assert.ok(Object.isFrozen(first)); + assert.ok(Object.isFrozen(first.messages)); + assert.ok(Object.isFrozen(first.messages[0])); + + for (const batch of encodeDesktopTranscriptChange(identity, { + durableThrough: 2, + durableUpserts: [{ sequence: 2, message: secondMessage }], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + + const second = store.snapshot(); + assert.notStrictEqual(second, first); + assert.strictEqual(second.messages[0], first.messages[0]); + assert.deepEqual(second.messages, [firstMessage, secondMessage]); +}); + +test('bounds the default active transcript range by Turn identities', async () => { + const messages = Array.from({ length: 200 }, (_, sequence) => ({ + identity: sequence, + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, + })); + const bootstrapPage = transcriptPage('older', null, messages.length - 1); + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: messages.length - 1, + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, messages.length - 1), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages, nextCursor: null }), + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + const snapshot = replica.snapshot(); + assert.equal( + new Set(snapshot.durable.map(({ message }) => message.turnId)).size, + DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + ); + assert.equal( + snapshot.durable[0]?.sequence, + messages.length - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + ); + assert.equal(snapshot.durable.at(-1)?.sequence, 199); + assert.equal(snapshot.hasOlder, true); + assert.equal(snapshot.hasNewer, false); +}); + +test('bounds the default active transcript range by presentation bytes', async () => { + const messages = syntheticLargeTranscript(); + const bootstrapPage = transcriptPage('older', null, messages.length - 1); + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: messages.length - 1, + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, messages.length - 1), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages, nextCursor: null }), + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + const snapshot = replica.snapshot(); + const bytes = snapshot.durable.reduce( + (total, { message }) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), + 0, + ); + assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); + assert.equal(snapshot.hasOlder, true); + assert.equal(snapshot.hasNewer, false); +}); + 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}`), + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, })); const page = (nextCursor: string | null) => ({ kind: 'page' as const, @@ -239,19 +353,23 @@ test('keeps a bounded contiguous window while moving between history and the tai decodeTranscriptPage: async (candidate) => candidate === bootstrapPage ? { messages: messages.slice(3), nextCursor: 'older' } : candidate === olderPage - ? { messages: messages.slice(2, 4), nextCursor: 'older' } + ? { messages: messages.slice(1, 3), nextCursor: 'older' } : { messages: messages.slice(4), nextCursor: null }, - loadTranscriptPage: async (input) => input.anchorSequence === 4 ? olderPage : latestPage, + loadTranscriptPage: async (input) => input.anchorSequence === 3 ? olderPage : latestPage, async close() {}, }); - const maxResidentBytes = Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') + 1; + 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), [4]); - await replica.loadBefore(4, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2]); + 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); @@ -260,6 +378,47 @@ test('keeps a bounded contiguous window while moving between history and the tai 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); +}); + test('delivers a mid-session tail append even while a history window is resident', async () => { // Reproduces the "active session does not show the newest message until you // switch away and back" bug. Once the resident window has been trimmed off @@ -270,7 +429,10 @@ test('delivers a mid-session tail append even while a history window is resident // and only a fresh subscription (session switch) re-read it. const messages = [0, 1, 2, 3, 4].map((sequence) => ({ identity: sequence, - message: assistantMessage(String(sequence), `assistant-${sequence}`), + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, })); const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; const page = (nextCursor: string | null) => ({ @@ -305,17 +467,21 @@ test('delivers a mid-session tail append even while a history window is resident ? { messages: messages.slice(3), nextCursor: 'older' } : candidate === tailPage ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(2, 4), 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') + 1; + 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(4, 128 * 1024); + await replica.loadBefore(3, 128 * 1024); assert.equal(replica.snapshot().hasNewer, true); changes.splice(0); @@ -335,7 +501,10 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' // 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}`), + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, })); const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; const page = (nextCursor: string | null) => ({ @@ -376,7 +545,7 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' ? { messages: messages.slice(3), nextCursor: 'older' } : candidate === tailPage ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(2, 4), 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, @@ -389,13 +558,17 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' }, async close() {}, }); - const maxResidentBytes = Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') + 1; + 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(4, 128 * 1024); + await replica.loadBefore(3, 128 * 1024); assert.equal(replica.snapshot().hasNewer, true); changes.splice(0); @@ -849,8 +1022,14 @@ test('forwards a larger logical history range without changing batch size', asyn sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 1, - durable: [{ sequence: 1, message: assistantMessage('latest') }], + durableThrough: 2, + durable: [ + { sequence: 1, message: assistantMessage('earlier') }, + { + sequence: 2, + message: { ...assistantMessage('latest', 'assistant-2'), turnId: 'turn-2' }, + }, + ], overlay: [], hasOlder: true, hasNewer: false, @@ -868,9 +1047,9 @@ test('forwards a larger logical history range without changing batch size', asyn async close() {}, })); - await controller.loadBefore(512 * 1024); + await controller.loadBefore(512 * 1024, 'turn-2'); - assert.deepEqual(request, { anchorSequence: 1, maxBytes: 512 * 1024 }); + assert.deepEqual(request, { anchorSequence: 2, maxBytes: 512 * 1024 }); await controller.close(); }); diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index ba7690b948..0988ac8275 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -110,7 +110,7 @@ describe('single live-turn handoff', () => { onNew() {}, } satisfies Parameters[0])); - assert.equal((markup.match(/data-virtual-turn-id=/g) ?? []).length, 1); + assert.equal((markup.match(/data-transcript-turn-id=/g) ?? []).length, 1); assert.match(markup, /data-transient-message-id="message-pending"/); assert.match(markup, />send now { assert.doesNotMatch(markup, /maka-chat-message-loading/); assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="turn-1"')); assert.equal((markup.match(/data-transient-message-id="turn-1"/g) ?? []).length, 1); - assert.equal((markup.match(/data-virtual-turn-id="turn-1"/g) ?? []).length, 1); + assert.equal((markup.match(/data-transcript-turn-id="turn-1"/g) ?? []).length, 1); }); it('keeps an unresolved root transient before a live Turn that arrived before IPC settled', () => { diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index c41b994945..f596b4e650 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -26,9 +26,10 @@ import { import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; import type { SessionTranscriptPage } from '@maka/runtime-host/protocol'; import { + DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES, + DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -36,6 +37,7 @@ export interface DesktopTranscriptReplicaOptions { readonly generation?: string; readonly maxMessageBytes?: number; readonly maxResidentBytes?: number; + readonly maxResidentTurns?: number; readonly maxOverlayBytes?: number; readonly accountPreparationBytes?: (deltaBytes: number) => void; readonly onChange?: ( @@ -79,6 +81,7 @@ export class DesktopTranscriptReplica { readonly hostEpoch: string; readonly #handle: DesktopRuntimeHostSession; readonly #maxResidentBytes: number; + readonly #maxResidentTurns: number; readonly #maxOverlayBytes: number; readonly #maxMessageBytes: number; readonly #accountPreparationBytes: (deltaBytes: number) => void; @@ -109,7 +112,9 @@ export class DesktopTranscriptReplica { this.generation = options.generation ?? randomUUID(); this.hostEpoch = handle.hostEpoch; this.#maxResidentBytes = - options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES; + options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; + this.#maxResidentTurns = + options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS; this.#maxOverlayBytes = options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; this.#maxMessageBytes = options.maxMessageBytes ?? DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES; @@ -248,7 +253,11 @@ export class DesktopTranscriptReplica { } const completedOverlayMessageIds = this.#installDurable(decoded.messages); this.#hasOlder = decoded.nextCursor !== null; - const evictedDurableSequences = this.#evictToBudget(undefined, 'newest'); + const evictedDurableSequences = this.#evictToBudget( + undefined, + 'newest', + anchor ?? undefined, + ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); } @@ -303,7 +312,7 @@ export class DesktopTranscriptReplica { this.#hasOlder = loadTail ? decoded.nextCursor !== null : sequence > 0; this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; evictedDurableSequences.push( - ...this.#evictToBudget(undefined, loadTail ? 'oldest' : 'newest'), + ...this.#evictToBudget(undefined, loadTail ? 'oldest' : 'newest', sequence), ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); @@ -525,19 +534,71 @@ export class DesktopTranscriptReplica { #evictToBudget( budget: number | undefined = undefined, edge: 'oldest' | 'newest' = 'oldest', + protectedSequence?: number, ): number[] { const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; const evicted: number[] = []; - const direction = edge === 'oldest' ? 1 : -1; - for (const sequence of [...this.#durable.keys()].sort((left, right) => direction * (left - right))) { - if (this.#residentBytes <= residentBudget) break; + 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; - this.#durable.delete(sequence); - this.#adjustResidentBytes(-entry.encodedBytes); - if (edge === 'oldest') this.#hasOlder = true; + const turnKey = residentTurnKey(entry); + const group = turnGroups.get(turnKey); + if (group) group.push(sequence); + else turnGroups.set(turnKey, [sequence]); + } + const orderedTurns = [...turnGroups.entries()]; + let oldestIndex = 0; + let newestIndex = orderedTurns.length - 1; + let residentTurns = orderedTurns.length; + const protectedEntry = protectedSequence === undefined + ? undefined + : this.#durable.get(protectedSequence); + const protectedTurnKey = protectedEntry === undefined + ? undefined + : residentTurnKey(protectedEntry); + const protectedIndex = protectedTurnKey === undefined + ? -1 + : orderedTurns.findIndex(([turnKey]) => turnKey === protectedTurnKey); + 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 || turn[0] === protectedTurnKey) return undefined; + if (candidateEdge === 'oldest') oldestIndex += 1; + else newestIndex -= 1; + return turn; + }; + while ( + this.#residentBytes > residentBudget + || residentTurns > this.#maxResidentTurns + ) { + let evictionEdge = protectedIndex < 0 + ? edge + : protectedIndex - oldestIndex > newestIndex - protectedIndex + ? 'oldest' + : protectedIndex - oldestIndex < newestIndex - protectedIndex + ? 'newest' + : edge; + let turn = take(evictionEdge); + if (turn === undefined) { + evictionEdge = edge === 'oldest' ? 'newest' : 'oldest'; + turn = take(evictionEdge); + } + if (turn === undefined) break; + for (const sequence of turn[1]) { + 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; - evicted.push(sequence); } return evicted; } @@ -634,6 +695,11 @@ function encodedMessageBytes(message: StoredMessage): number { return Buffer.byteLength(JSON.stringify(message), 'utf8'); } +function residentTurnKey(entry: ResidentMessage): string { + const turnId = 'turnId' in entry.message ? entry.message.turnId : undefined; + return typeof turnId === 'string' ? `turn:${turnId}` : `sequence:${entry.sequence}`; +} + function correlationError(message: string): RuntimeHostSubscriptionError { return new RuntimeHostSubscriptionError('correlation_changed', message); } diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index a6020dc291..1e1cd6a535 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -35,7 +35,7 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; -/** Exceeds both the 64-tick rail and 100-turn mounted-window bounds. */ +/** Exceeds both the 64-tick rail and the bounded active transcript range. */ export const PROMPT_RAIL_PROMPT_COUNT = 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; export const LONG_SIDEBAR_SESSION_COUNT = 60; diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 51b1880233..12f7e01ab1 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -19,7 +19,7 @@ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; -export const DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES = 20 * 1024 * 1024; +export const DESKTOP_TRANSCRIPT_ACTIVE_RANGE_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 const DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES = 16 * 1024 * 1024; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6bded74bc..6b04595330 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2618,7 +2618,7 @@ function AppShellContent({ } catch { activeTranscriptRange = undefined; } - async function loadTranscriptHistory(target: 'earlier' | 'latest') { + async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { const controller = transcriptRangeRef.current; const sessionId = activeId; if (!controller || !sessionId || historyLoadPendingRef.current) return; @@ -2626,7 +2626,7 @@ function AppShellContent({ setHistoryLoadPendingSessionId(sessionId); try { if (target === 'earlier') { - await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); } else { await controller.loadLatest(); } @@ -3098,7 +3098,8 @@ function AppShellContent({ hasOlderHistory={activeTranscriptRange?.hasOlder === true} hasNewerHistory={activeTranscriptRange?.hasNewer === true} historyLoadPending={historyLoadPendingSessionId === activeId} - onLoadEarlierHistory={() => loadTranscriptHistory('earlier')} + onLoadEarlierHistory={(anchorTurnId) => + loadTranscriptHistory('earlier', anchorTurnId)} onReturnToLatestHistory={() => loadTranscriptHistory('latest')} liveContentSeedRevision={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 d993aca8aa..26ab8a7e4e 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -91,7 +91,7 @@ interface ChatMessageSurfaceProps extends Omit< hasOlderHistory: boolean; hasNewerHistory: boolean; historyLoadPending: boolean; - onLoadEarlierHistory: () => Promise | void; + onLoadEarlierHistory: (anchorTurnId?: string) => Promise | void; onReturnToLatestHistory: () => Promise | void; } diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts index de5500bd3a..1738e40d37 100644 --- a/apps/desktop/src/renderer/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts @@ -31,7 +31,7 @@ export interface DesktopTranscriptRangeController { readonly store: DesktopTranscriptRangeStore; ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; - loadBefore(maxBytes?: number): Promise; + loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; loadAround(sequence: number): Promise; loadLatest(): Promise; reload(): Promise; @@ -58,10 +58,15 @@ export function createDesktopTranscriptRangeController( await current(); return store.waitForDurableMessage(messageId, timeoutMs); }, - async loadBefore(maxBytes) { + async loadBefore(maxBytes, anchorTurnId) { const range = store.range(); if (!range.hasOlder) return; - await (await current()).loadBefore(range.oldestSequence, maxBytes); + await (await current()).loadBefore( + anchorTurnId === undefined + ? range.oldestSequence + : store.sequenceForTurn(anchorTurnId) ?? range.oldestSequence, + maxBytes, + ); }, async loadAround(sequence) { await (await current()).loadAround(sequence); @@ -104,6 +109,7 @@ interface PendingRecord { interface StoredRecord { readonly message: StoredMessage; + readonly encoded: string; } interface OverlayRecord extends StoredRecord { @@ -132,6 +138,8 @@ export class DesktopTranscriptRangeStore { readonly #expectedSessionId: string; readonly #durable = new Map(); readonly #overlay = new Map(); + readonly #durableOrder: number[] = []; + readonly #overlayOrder: string[] = []; readonly #pending = new Map(); #sourceSessionId: string | undefined; #generation: string | undefined; @@ -144,6 +152,7 @@ export class DesktopTranscriptRangeStore { #hasNewer = false; #ready = false; #batchChanged = false; + #snapshot: DesktopTranscriptRangeSnapshot | undefined; readonly #durableWaiters = new Set<() => void>(); constructor(sessionKey: string) { @@ -172,12 +181,16 @@ export class DesktopTranscriptRangeStore { this.#hasNewer = batch.hasNewer; for (const sequence of batch.evictedDurableSequences) { if (this.#durable.delete(sequence)) { + removeOrdered(this.#durableOrder, sequence); this.#refreshSequenceBounds(sequence); changed = true; } } for (const messageId of batch.completedOverlayMessageIds) { - changed = this.#overlay.delete(messageId) || changed; + if (this.#overlay.delete(messageId)) { + removeOrdered(this.#overlayOrder, messageId); + changed = true; + } } for (const fragment of batch.fragments) { changed = this.#acceptFragment(fragment) || changed; @@ -190,20 +203,14 @@ export class DesktopTranscriptRangeStore { if (!batch.ready) return false; const committed = this.#batchChanged; this.#batchChanged = false; + if (committed) this.#snapshot = this.#createSnapshot(); for (const notify of this.#durableWaiters) notify(); return committed; } snapshot(): DesktopTranscriptRangeSnapshot { - const range = this.range(); - const durable = [...this.#durable.entries()].sort(([left], [right]) => left - right); - const overlay = [...this.#overlay.values()].sort((left, right) => left.order - right.order); - return { - ...range, - messages: durable - .map(([, record]) => structuredClone(record.message)) - .concat(overlay.map((record) => structuredClone(record.message))), - }; + this.#snapshot ??= this.#createSnapshot(); + return this.#snapshot; } range(): DesktopTranscriptRangeState { @@ -234,6 +241,13 @@ export class DesktopTranscriptRangeStore { return this.#newestUserSequence; } + sequenceForTurn(turnId: string): number | null { + for (const sequence of this.#durableOrder) { + if (this.#durable.get(sequence)?.message.turnId === turnId) return sequence; + } + return null; + } + waitForDurableMessage(messageId: string, timeoutMs: number): Promise { if (this.hasDurableMessage(messageId)) return Promise.resolve(true); return new Promise((resolve) => { @@ -257,6 +271,8 @@ export class DesktopTranscriptRangeStore { } this.#durable.clear(); this.#overlay.clear(); + this.#durableOrder.length = 0; + this.#overlayOrder.length = 0; this.#pending.clear(); this.#sourceSessionId = batch.sessionId; this.#generation = batch.generation; @@ -269,6 +285,7 @@ export class DesktopTranscriptRangeStore { this.#hasNewer = batch.hasNewer; this.#ready = false; this.#batchChanged = false; + this.#snapshot = undefined; } #acceptFragment(fragment: DesktopTranscriptFragment): boolean { @@ -307,10 +324,10 @@ export class DesktopTranscriptRangeStore { pending.receivedBytes += bytes.byteLength; if (pending.receivedBytes < pending.totalBytes) return false; const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); - const message = projectDesktopStoredMessage( + 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') { @@ -319,10 +336,12 @@ export class DesktopTranscriptRangeStore { } const sequence = pending.identity as number; const existing = this.#durable.get(sequence); - if (existing && JSON.stringify(existing.message) !== projected) { + if (existing && existing.encoded !== projected) { throw new Error('Desktop transcript durable record changed'); } - this.#durable.set(sequence, { message }); + if (existing) return false; + this.#durable.set(sequence, { message, encoded: projected }); + insertOrdered(this.#durableOrder, sequence, (left, right) => left - right); this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); this.#newestSequence = Math.max(this.#newestSequence ?? sequence, sequence); if (message.type === 'user') { @@ -337,24 +356,70 @@ export class DesktopTranscriptRangeStore { throw new Error('Invalid Desktop transcript overlay order'); } const existing = this.#overlay.get(pending.identity); + if ( + existing + && existing.encoded === projected + && existing.order === pending.order + ) { + return false; + } + if (existing) removeOrdered(this.#overlayOrder, pending.identity); this.#overlay.set(pending.identity, { message, + encoded: projected, order: pending.order, }); - return ( - !existing || - JSON.stringify(existing.message) !== projected || - existing.order !== pending.order + insertOrdered( + this.#overlayOrder, + pending.identity, + (left, right) => { + const order = this.#overlay.get(left)!.order - this.#overlay.get(right)!.order; + return order === 0 ? left.localeCompare(right) : order; + }, ); + return true; } #refreshSequenceBounds(deletedSequence: number): void { if (deletedSequence !== this.#oldestSequence && deletedSequence !== this.#newestSequence) return; - this.#oldestSequence = null; - this.#newestSequence = null; - for (const sequence of this.#durable.keys()) { - this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); - this.#newestSequence = Math.max(this.#newestSequence ?? sequence, sequence); - } + 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), + ...this.#overlayOrder.map((messageId) => this.#overlay.get(messageId)!.message), + ]); + return Object.freeze({ + ...this.range(), + messages, + }); + } +} + +function freezeTranscriptValue(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) freezeTranscriptValue(child); + return Object.freeze(value); +} + +function insertOrdered( + items: T[], + value: T, + compare: (left: T, right: T) => number, +): void { + let low = 0; + let high = items.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (compare(items[middle]!, value) <= 0) low = middle + 1; + else high = middle; + } + items.splice(low, 0, value); +} + +function removeOrdered(items: T[], value: T): void { + const index = items.indexOf(value); + if (index >= 0) items.splice(index, 1); } diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index dfddf83464..9fa775b629 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -52,12 +52,13 @@ overflow-anchor: auto; } -.maka-turn-virtual-item { +.maka-transcript-turn { display: flex; width: 100%; flex-direction: column; gap: var(--spacing-4); - contain: layout style; + content-visibility: auto; + contain-intrinsic-block-size: auto 280px; } .maka-chat-message-loading { diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index 3812b11441..b1e806c628 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -32,7 +32,7 @@ import { /** * The e2e suite cannot stage what these cover. Whether a jump survives depends - * on which frame the virtual window lands on, and the e2e case went green + * 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. */ diff --git a/packages/ui/src/__tests__/turn-height-index.test.ts b/packages/ui/src/__tests__/turn-height-index.test.ts deleted file mode 100644 index 1bb1365ec4..0000000000 --- a/packages/ui/src/__tests__/turn-height-index.test.ts +++ /dev/null @@ -1,44 +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 { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { createTurnHeightIndex } from '../turn-height-index.js'; - -describe('turn height index', () => { - it('keeps measurements for the current layout and bounds old sessions and turns', () => { - const index = createTurnHeightIndex(2, 2); - assert.equal(index.record('s1', 'wide', 'a', 100), true); - assert.equal(index.record('s1', 'wide', 'a', 100.2), false); - index.record('s1', 'wide', 'b', 200); - index.record('s1', 'wide', 'c', 300); - assert.equal(index.lookup('s1', 'wide')?.has('a'), false); - index.record('s2', 'wide', 'a', 100); - index.record('s3', 'wide', 'a', 100); - assert.equal(index.lookup('s1', 'wide'), undefined); - }); - - it('does not reuse heights after the layout changes', () => { - const index = createTurnHeightIndex(); - index.record('s1', 'wide', 'a', 100); - index.record('s1', 'narrow', 'a', 200); - assert.equal(index.lookup('s1', 'wide'), undefined); - assert.equal(index.lookup('s1', 'narrow')?.get('a'), 200); - }); -}); diff --git a/packages/ui/src/__tests__/turn-virtualizer.test.ts b/packages/ui/src/__tests__/turn-virtualizer.test.ts deleted file mode 100644 index 59ab222da3..0000000000 --- a/packages/ui/src/__tests__/turn-virtualizer.test.ts +++ /dev/null @@ -1,208 +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 { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { - buildTurnVirtualLayout, - estimatedTurnHeight, - initialTurnVirtualWindow, - reconcileTurnVirtualWindow, - stableTurnVirtualWindowForViewport, - turnVirtualWindowForRange, - turnVirtualWindowForViewport, - turnVirtualizationRequired, -} from '../turn-virtualizer.js'; - -const ids = (count: number) => Array.from({ length: count }, (_, index) => `t${index}`); - -describe('turn virtualizer', () => { - it('uses observed turns to estimate unmeasured history', () => { - assert.equal(estimatedTurnHeight(undefined), 280); - assert.equal(estimatedTurnHeight(new Map([['short', 100]])), 190); - assert.equal(estimatedTurnHeight(new Map([['a', 600], ['b', 1_000]])), 1_880 / 3); - }); - - it('starts with a bounded tail and exact spacer geometry', () => { - const layout = buildTurnVirtualLayout(ids(200), undefined, { estimatedHeight: 100, gap: 4 }); - const window = initialTurnVirtualWindow(layout, 40); - assert.deepEqual({ start: window.start, end: window.end }, { start: 160, end: 200 }); - assert.equal(window.beforeHeight, (160 * 100) + (159 * 4)); - assert.equal(window.afterHeight, 0); - }); - - it('recomputes spacer geometry without changing the mounted range', () => { - const turnIds = ids(100); - const estimated = buildTurnVirtualLayout(turnIds, undefined, { estimatedHeight: 100 }); - const initial = initialTurnVirtualWindow(estimated, 40); - const measured = buildTurnVirtualLayout( - turnIds, - new Map(turnIds.slice(0, 60).map((id) => [id, 200])), - { estimatedHeight: 100 }, - ); - const updated = turnVirtualWindowForRange(measured, initial.start, initial.end); - assert.deepEqual( - { start: updated.start, end: updated.end }, - { start: initial.start, end: initial.end }, - ); - assert.ok(updated.beforeHeight > initial.beforeHeight); - }); - - it('uses measurements and keeps a pixel overscan without exceeding the hard cap', () => { - const turnIds = ids(500); - const heights = new Map(turnIds.map((id, index) => [id, index % 2 === 0 ? 40 : 400])); - const layout = buildTurnVirtualLayout(turnIds, heights); - const window = turnVirtualWindowForViewport( - layout, - { scrollTop: 40_000, clientHeight: 900 }, - { overscanPx: 1_200, preferredTurns: 60, maxTurns: 100 }, - ); - assert.ok(window.end - window.start >= 60); - assert.ok(window.end - window.start <= 100); - assert.ok(layout.offsets[window.start]! <= 40_000); - assert.ok(layout.offsets[window.end]! >= 40_900); - }); - - it('keeps the mounted window stable until the viewport reaches its overscan edge', () => { - const layout = buildTurnVirtualLayout( - ids(120), - undefined, - { estimatedHeight: 200, gap: 4 }, - ); - const current = turnVirtualWindowForViewport( - layout, - { scrollTop: 0, clientHeight: 800 }, - ); - assert.deepEqual([current.start, current.end], [0, 60]); - - const inside = stableTurnVirtualWindowForViewport( - layout, - current, - { scrollTop: 8_000, clientHeight: 800 }, - ); - assert.deepEqual([inside.start, inside.end], [0, 60]); - - const crossed = stableTurnVirtualWindowForViewport( - layout, - current, - { scrollTop: 11_000, clientHeight: 800 }, - ); - assert.equal(crossed.end - crossed.start, 60); - assert.equal(crossed.start, current.start + 8); - }); - - it('retains focused and selected turns when a directional shift fits the hard cap', () => { - const layout = buildTurnVirtualLayout( - ids(120), - undefined, - { estimatedHeight: 200, gap: 4 }, - ); - const current = turnVirtualWindowForRange(layout, 0, 60); - const shifted = stableTurnVirtualWindowForViewport( - layout, - current, - { scrollTop: 11_000, clientHeight: 800 }, - { retainRange: { start: 0, end: 8 } }, - ); - - assert.deepEqual([shifted.start, shifted.end], [0, 68]); - }); - - it('keeps visible identities across prepends and batched appends', () => { - const beforeIds = ids(100); - const beforeLayout = buildTurnVirtualLayout(beforeIds, undefined); - const before = turnVirtualWindowForViewport(beforeLayout, { scrollTop: 8_000, clientHeight: 800 }); - const prepended = ['p0', 'p1', ...beforeIds]; - const afterPrepend = reconcileTurnVirtualWindow( - beforeIds, - buildTurnVirtualLayout(prepended, undefined), - before, - ); - assert.equal(prepended[afterPrepend.start], beforeIds[before.start]); - assert.equal(prepended[afterPrepend.end - 1], beforeIds[before.end - 1]); - - const tail = initialTurnVirtualWindow(beforeLayout, 40); - const appended = [...beforeIds, ...ids(80).map((id) => `a${id}`)]; - const afterAppend = reconcileTurnVirtualWindow( - beforeIds, - buildTurnVirtualLayout(appended, undefined), - tail, - ); - assert.equal(appended[afterAppend.start], beforeIds[tail.start]); - assert.equal(appended[afterAppend.end - 1], beforeIds[tail.end - 1]); - assert.equal(afterAppend.end - afterAppend.start, 40); - }); - - it('virtualizes by rendered distance before the turn-count cap', () => { - const compact = buildTurnVirtualLayout(ids(8), undefined, { estimatedHeight: 90 }); - const tall = buildTurnVirtualLayout(ids(8), undefined, { estimatedHeight: 500 }); - - assert.equal(turnVirtualizationRequired(compact, 800), false); - assert.equal(turnVirtualizationRequired(compact, 800, 4_000), true); - assert.equal(turnVirtualizationRequired(tall, 800), true); - assert.equal(turnVirtualizationRequired(buildTurnVirtualLayout(ids(101), undefined), undefined), true); - }); - - it('expands a window that cannot cover the viewport without oscillating', () => { - const layout = buildTurnVirtualLayout( - ids(10), - new Map([ - ['t4', 4_800], - ['t5', 1_000], - ]), - ); - const undersized = turnVirtualWindowForRange(layout, 4, 5); - const recovered = stableTurnVirtualWindowForViewport( - layout, - undersized, - { scrollTop: 5_000, clientHeight: 800 }, - ); - - assert.deepEqual([recovered.start, recovered.end], [0, 10]); - assert.deepEqual( - stableTurnVirtualWindowForViewport( - layout, - recovered, - { scrollTop: 5_000, clientHeight: 800 }, - ), - recovered, - ); - }); - - it('brings an explicit target into the bounded window', () => { - const turnIds = ids(1_000); - const layout = buildTurnVirtualLayout(turnIds, undefined); - const tail = initialTurnVirtualWindow(layout, 40); - const target = reconcileTurnVirtualWindow(turnIds, layout, tail, 10); - assert.ok(target.start <= 10 && target.end > 10); - assert.ok(target.end - target.start <= 100); - }); - - it('keeps the viewport bounded when a retained turn is too far away', () => { - const layout = buildTurnVirtualLayout(ids(1_000), undefined, { estimatedHeight: 100 }); - const window = turnVirtualWindowForViewport( - layout, - { scrollTop: 80_000, clientHeight: 800 }, - { maxTurns: 100, ensureIndex: 10 }, - ); - assert.equal(window.end - window.start, 100); - assert.ok(layout.offsets[window.start]! <= 80_000); - assert.ok(layout.offsets[window.end]! >= 80_800); - }); -}); diff --git a/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx b/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx deleted file mode 100644 index 177ee68518..0000000000 --- a/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx +++ /dev/null @@ -1,134 +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 { afterEach, test } from 'node:test'; -import { act, useRef } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import { useTurnVirtualizer } from '../use-turn-virtualizer.js'; - -const originalGlobals = { - document: globalThis.document, - Element: globalThis.Element, - HTMLElement: globalThis.HTMLElement, - MutationObserver: globalThis.MutationObserver, - Node: globalThis.Node, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - ResizeObserver: globalThis.ResizeObserver, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; - -let mountedRoot: ReturnType | undefined; - -afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); -}); - -test('remeasures a short transcript when tall turns require virtualization', async () => { - const { document, window } = parseHTML('
'); - const root = document.querySelector('#root'); - assert.ok(root); - let scrollHeight = 0; - Object.defineProperties(root, { - clientHeight: { value: 800 }, - clientWidth: { value: 800 }, - scrollHeight: { get: () => scrollHeight }, - scrollTop: { value: 0, writable: true }, - }); - Object.defineProperty(document, 'getSelection', { value: () => null }); - - const frames = new Map(); - let nextFrame = 1; - class TestResizeObserver { - static latest: TestResizeObserver | undefined; - constructor(private readonly callback: ResizeObserverCallback) { - TestResizeObserver.latest = this; - } - disconnect() {} - observe() {} - unobserve() {} - emit(entries: ResizeObserverEntry[]) { - this.callback(entries, this as unknown as ResizeObserver); - } - } - class TestMutationObserver { - disconnect() {} - observe() {} - takeRecords(): MutationRecord[] { return []; } - } - Object.assign(globalThis, { - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - MutationObserver: TestMutationObserver, - Node: window.Node, - requestAnimationFrame: (callback: FrameRequestCallback) => { - const id = nextFrame; - nextFrame += 1; - frames.set(id, callback); - return id; - }, - cancelAnimationFrame: (id: number) => frames.delete(id), - ResizeObserver: TestResizeObserver, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - - const turnIds = Array.from({ length: 8 }, (_, index) => `turn-${index}`); - function Harness() { - const scrollRef = useRef(root); - const range = useTurnVirtualizer({ sessionId: 'tall-session', turnIds, scrollRef }); - return ( -
- {turnIds.slice(range.start, range.end).map((turnId) => ( -
- ))} -
- ); - } - - mountedRoot = createRoot(root); - await act(() => mountedRoot?.render()); - assert.equal(root.querySelector('[data-range]')?.getAttribute('data-range'), '0:8'); - - const observer = TestResizeObserver.latest; - assert.ok(observer); - scrollHeight = 4_800; - const entries = Array.from(root.querySelectorAll('[data-virtual-turn-id]')).map( - (target) => ({ target, borderBoxSize: [{ blockSize: 600 }] }) as unknown as ResizeObserverEntry, - ); - await act(() => observer.emit(entries)); - await act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - - assert.notEqual(root.querySelector('[data-range]')?.getAttribute('data-range'), '0:8'); -}); diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index ab3be1b6b5..35e60da9a5 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -40,7 +40,7 @@ export type ChatSurfaceLayoutProps = Omit, 'au * their own content rather than a `ChatView`. `host` turns Astryx's scroll * layer off entirely — no listeners, no spring — and hands `scrollTop` to * Maka's single authority, which is what a `ChatView` transcript needs: it - * knows turn identity, the virtual window and the navigation the reader + * knows turn identity, the Host active range and the navigation the reader * asked for, none of which a generic scroll container can see. */ scrollOwner?: 'astryx' | 'host'; diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 2eb8c288d4..80be6a215a 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -58,7 +58,6 @@ import { } from './chat-turn.js'; import { useChatScroll } from './use-chat-scroll.js'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; -import { useTurnVirtualizer } from './use-turn-virtualizer.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; @@ -264,7 +263,7 @@ export function ChatView(props: { scrollTargetTurn?: { turnId: string; nonce: number }; scrollBehavior: ScrollBehavior; hasOlderHistory?: boolean; - onLoadEarlierHistory?(): Promise | void; + onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; returnToLatest?: { title: string; label: string; @@ -505,31 +504,14 @@ export function ChatView(props: { } const scrollRef = chatLayout.scrollContainerRef; const scrollAuthority = useTranscriptScrollAuthority(); - const orderedTurnIds = useMemo(() => turns.map((turn) => turn.turnId), [turns]); - const sessionId = props.activeSession?.id; - const { - start: mountStart, - end: mountEnd, - beforeHeight, - afterHeight, - revealTurn, - } = useTurnVirtualizer({ - sessionId, - turnIds: orderedTurnIds, - scrollRef, - targetTurnId: props.scrollTargetTurn?.turnId, - targetKey: props.scrollTargetTurn?.nonce, - }); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { - if (turnIdsRef.current.has(turn.turnId)) revealTurn(turn.turnId); - else if (turn.sequence !== undefined) { + if (!turnIdsRef.current.has(turn.turnId) && turn.sequence !== undefined) { loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); } - }, [revealTurn]); - const mountedTurns = turns.slice(mountStart, mountEnd); + }, []); const inlineTransientMessages = tailTurnId ? transientMessages.filter((message) => { - const turn = mountedTurns.find((candidate) => candidate.turnId === tailTurnId); + const turn = turns.find((candidate) => candidate.turnId === tailTurnId); if ( turn === undefined || turn.user !== undefined @@ -719,19 +701,12 @@ export function ChatView(props: { {showEmptyState ? null : ( <> {chat.length === 0 && !streamingActive ? emptyContent : null} - {beforeHeight > 0 && ( -