You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/30 05:42:18 UTC
(maka) branch main updated: perf(desktop): bound transcript rendering and eliminate scroll LoAF (#4206)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new c33617fd8b perf(desktop): bound transcript rendering and eliminate scroll LoAF (#4206)
c33617fd8b is described below
commit c33617fd8b2a2f6f0d34f053b19428501e9c9bdb
Author: AstroHan <le...@outlook.com>
AuthorDate: Sun Aug 30 13:42:14 2026 +0800
perf(desktop): bound transcript rendering and eliminate scroll LoAF (#4206)
* 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
* test(desktop): add transcript CDP performance gate
Record reproducible warm-scroll, memory, DOM, session-switch, and repeated-paging evidence without participating in product state.
Generated-by: Codex
* perf(desktop): isolate transcript active-state styling
Project the existing onboarding composition state directly onto ChatLayout so prompt-rail aria-current updates no longer invalidate the full transcript through ancestor :has selectors.
Generated-by: Codex
* test(desktop): await prompt jump landing atomically
Poll both sides of the prompt landing tolerance in one sample so faster transcript styling cannot expose an intermediate smooth-scroll position as a failure.
Generated-by: Codex
* test(desktop): make transcript stress gate reproducible
Generated-by: Codex
* test(desktop): require measurable transcript LoAF
Generated-by: Codex
* test(desktop): keep transcript perf harness baseline-compatible
Generated-by: Codex
* test(desktop): gate full transcript heap sweeps
Generated-by: Codex
* fix(desktop): retain oversized active turns
Generated-by: Codex
* fix(ui): preserve nested transcript scrolling
Generated-by: Codex
* fix(desktop): protect latest transcript turn
Generated-by: Codex
* test(desktop): stabilize nested scroll hit testing
Generated-by: Codex
* fix(desktop): publish complete transcript turn ranges
Make the Runtime Host own the bounded edge-Turn boundary so the Renderer never has to infer or retain a partial Turn.
Generated-by: Codex
---
apps/desktop/e2e/native-transcript-perf.spec.ts | 441 +++++++++++++++++++
apps/desktop/e2e/onboarding-viewport.spec.ts | 2 +
apps/desktop/e2e/prompt-rail.spec.ts | 176 +++++---
apps/desktop/e2e/transcript-scroll.spec.ts | 147 +++++--
.../desktop-transcript-range-store.test.ts | 476 ++++++++++++++++++++-
.../src/main/__tests__/runtime-host-client.test.ts | 2 +
.../runtime-host-session-observer.test.ts | 18 +
.../__tests__/runtime-host-session-test-fixture.ts | 2 +
.../src/main/__tests__/streaming-handoff.test.ts | 4 +-
.../desktop/src/main/desktop-transcript-replica.ts | 119 +++++-
apps/desktop/src/main/e2e-fixture/seed-helpers.ts | 6 +-
.../src/main/runtime-host-session-observer.ts | 16 +-
apps/desktop/src/preload/transcript-contract.ts | 3 +-
apps/desktop/src/renderer/app-shell.tsx | 8 +-
apps/desktop/src/renderer/chat-message-surface.tsx | 2 +-
.../src/renderer/desktop-transcript-range-store.ts | 119 ++++--
apps/desktop/src/renderer/styles/chat-message.css | 5 +-
apps/desktop/src/renderer/styles/onboarding.css | 16 +-
apps/desktop/stories/onboarding.stories.tsx | 6 +-
.../src/__tests__/connection-session.test.ts | 6 +
.../__tests__/session-subscription-client.test.ts | 86 ++++
.../src/__tests__/session-transcript-pager.test.ts | 299 ++++++++++++-
.../__tests__/session-transcript-protocol.test.ts | 25 ++
.../src/client/session-subscription.ts | 32 +-
packages/runtime-host/src/protocol/index.ts | 4 +-
.../src/protocol/session-transcript.ts | 32 ++
.../src/server/session-transcript-pager.ts | 363 +++++++++++++---
.../ui/src/__tests__/prompt-anchor-rail.test.ts | 2 +-
.../ui/src/__tests__/turn-height-index.test.ts | 44 --
packages/ui/src/__tests__/turn-virtualizer.test.ts | 208 ---------
.../ui/src/__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 | 15 +-
packages/ui/src/use-turn-virtualizer.ts | 358 ----------------
38 files changed, 2188 insertions(+), 1501 deletions(-)
diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts
new file mode 100644
index 0000000000..4c7eafb09b
--- /dev/null
+++ b/apps/desktop/e2e/native-transcript-perf.spec.ts
@@ -0,0 +1,441 @@
+/*
+ * 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 type { CDPSession, Page } from '@playwright/test';
+import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers';
+import * as transcriptContract from '../src/preload/transcript-contract';
+import { ensureSidebarExpanded, expect, test } from './fixtures';
+
+const PERF_ENABLED = process.env.MAKA_TRANSCRIPT_PERF === '1';
+const STRESS_ENABLED = process.env.MAKA_TRANSCRIPT_STRESS === '1';
+const SCROLLER = '[data-chat-scroll-container="true"]';
+
+interface BrowserCounters {
+ heapBytes: number;
+ nodes: number;
+ documents: number;
+ jsEventListeners: number;
+}
+
+interface FrameSample {
+ intervals: number[];
+ loafDurations: number[];
+ loafSupported: boolean;
+}
+
+interface StressSample extends BrowserCounters {
+ sweep: number;
+ iteration: number;
+ firstTurnId: string | null;
+ lastTurnId: string | null;
+ mountedTurns: number;
+}
+
+interface StressSweep {
+ sweep: number;
+ successfulPages: number;
+ samples: StressSample[];
+}
+
+interface HeapGrowth {
+ endpointRatio: number;
+ slopeBytesPerPage: number;
+ projectedRatio: number;
+}
+
+/** The predeclared secondary heap/DOM release gate permits at most 10% growth. */
+const SECONDARY_RESOURCE_GROWTH_RATIO = 0.1;
+
+function positiveHeapGrowth(samples: readonly StressSample[]): HeapGrowth {
+ const first = samples[0]!;
+ const last = samples.at(-1)!;
+ const meanIteration = samples.reduce((sum, sample) => sum + sample.iteration, 0)
+ / samples.length;
+ const meanHeap = samples.reduce((sum, sample) => sum + sample.heapBytes, 0)
+ / samples.length;
+ const slopeNumerator = samples.reduce(
+ (sum, sample) => sum + (sample.iteration - meanIteration) * (sample.heapBytes - meanHeap),
+ 0,
+ );
+ const slopeDenominator = samples.reduce(
+ (sum, sample) => sum + (sample.iteration - meanIteration) ** 2,
+ 0,
+ );
+ const slopeBytesPerPage = slopeDenominator === 0 ? 0 : slopeNumerator / slopeDenominator;
+ const iterationSpan = last.iteration - first.iteration;
+ return {
+ endpointRatio: Math.max(0, last.heapBytes - first.heapBytes) / first.heapBytes,
+ slopeBytesPerPage,
+ projectedRatio: Math.max(0, slopeBytesPerPage * iterationSpan) / first.heapBytes,
+ };
+}
+
+function percentile(values: readonly number[], probability: number): number {
+ if (values.length === 0) return 0;
+ const ordered = [...values].sort((left, right) => left - right);
+ return ordered[Math.min(ordered.length - 1, Math.ceil(probability * ordered.length) - 1)]!;
+}
+
+async function collectGarbage(cdp: CDPSession): Promise<void> {
+ await cdp.send('HeapProfiler.enable');
+ await cdp.send('HeapProfiler.collectGarbage');
+}
+
+async function browserCounters(cdp: CDPSession): Promise<BrowserCounters> {
+ const [heap, dom] = await Promise.all([
+ cdp.send('Runtime.getHeapUsage'),
+ cdp.send('Memory.getDOMCounters'),
+ ]);
+ return {
+ heapBytes: heap.usedSize,
+ nodes: dom.nodes,
+ documents: dom.documents,
+ jsEventListeners: dom.jsEventListeners,
+ };
+}
+
+async function performanceMetrics(cdp: CDPSession): Promise<Map<string, number>> {
+ const result = await cdp.send('Performance.getMetrics');
+ return new Map(result.metrics.map(({ name, value }) => [name, value]));
+}
+
+function metricDelta(
+ before: ReadonlyMap<string, number>,
+ after: ReadonlyMap<string, number>,
+ name: string,
+): number {
+ return (after.get(name) ?? 0) - (before.get(name) ?? 0);
+}
+
+async function prepareFrameRecorder(page: Page): Promise<void> {
+ await page.evaluate(() => {
+ const state: FrameSample & { lastFrame: number | null; running: boolean } = {
+ intervals: [],
+ loafDurations: [],
+ loafSupported: PerformanceObserver.supportedEntryTypes
+ .includes('long-animation-frame'),
+ lastFrame: null,
+ running: false,
+ };
+ Object.assign(window, { __makaTranscriptPerf: state });
+ if (state.loafSupported) {
+ try {
+ const observer = new PerformanceObserver((list) => {
+ if (!state.running) return;
+ state.loafDurations.push(...list.getEntries().map((entry) => entry.duration));
+ });
+ observer.observe({ type: 'long-animation-frame', buffered: false });
+ } catch {
+ state.loafSupported = false;
+ }
+ }
+ });
+}
+
+async function scrollGesture(page: Page, delta: number, frames = 240): Promise<FrameSample> {
+ return page.evaluate(async ({ selector, delta, frames }) => {
+ type Recorder = FrameSample & { lastFrame: number | null; running: boolean };
+ const root = document.querySelector<HTMLElement>(selector);
+ const recorder = (window as Window & { __makaTranscriptPerf?: Recorder })
+ .__makaTranscriptPerf;
+ if (!root || !recorder) throw new Error('the transcript performance probe is missing');
+ recorder.intervals.length = 0;
+ recorder.loafDurations.length = 0;
+ recorder.lastFrame = null;
+ recorder.running = true;
+ const start = root.scrollTop;
+ await new Promise<void>((resolve) => {
+ let completed = 0;
+ const tick = (now: number) => {
+ if (recorder.lastFrame !== null) recorder.intervals.push(now - recorder.lastFrame);
+ recorder.lastFrame = now;
+ completed += 1;
+ root.scrollTop = start + (delta * completed) / frames;
+ if (completed >= frames) {
+ resolve();
+ return;
+ }
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ });
+ await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
+ recorder.running = false;
+ return {
+ intervals: [...recorder.intervals],
+ loafDurations: [...recorder.loafDurations],
+ loafSupported: recorder.loafSupported,
+ };
+ }, { selector: SCROLLER, delta, frames });
+}
+
+async function moveToTail(page: Page): Promise<void> {
+ await page.evaluate((selector) => {
+ const root = document.querySelector<HTMLElement>(selector);
+ if (!root) throw new Error('the chat scroll container is missing');
+ root.scrollTop = root.scrollHeight;
+ }, SCROLLER);
+}
+
+async function returnToLatest(page: Page): Promise<void> {
+ const returnLatest = page.getByRole('button', {
+ name: /^(?:返回最新消息|Return to latest)$/,
+ });
+ if (await returnLatest.isVisible()) await returnLatest.click();
+ else await page.locator('.maka-prompt-rail-tick').last().click({ force: true });
+}
+
+async function traverseFullHistoryAndReturnToTail(page: Page): Promise<void> {
+ for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) {
+ const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id');
+ if (firstBefore?.endsWith('-1')) break;
+ await page.evaluate((selector) => {
+ const root = document.querySelector<HTMLElement>(selector);
+ if (!root) throw new Error('the chat scroll container is missing');
+ root.scrollTop = 0;
+ root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
+ }, SCROLLER);
+ await expect.poll(async () =>
+ page.locator('[data-turn-id]').first().getAttribute('data-turn-id'),
+ ).not.toBe(firstBefore);
+ }
+ await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1);
+ await returnToLatest(page);
+ await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
+ .toHaveCount(1);
+}
+
+async function measureSessionSwitch(page: Page): Promise<number> {
+ 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(
+ (elements, current) => elements
+ .map((element) => element.getAttribute('data-session-id'))
+ .find((sessionId) => sessionId !== current) ?? null,
+ originalId,
+ );
+ if (!otherId) throw new Error('the fixture has no second Session');
+ const start = performance.now();
+ 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);
+ return performance.now() - start;
+}
+
+test('LoAF capability is explicit when Chromium cannot measure it', async ({
+ promptRailWindow: page,
+}) => {
+ test.skip(!PERF_ENABLED, 'manual same-build CDP A/B harness');
+ await page.evaluate(() => {
+ Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', {
+ configurable: true,
+ value: [],
+ });
+ });
+ await prepareFrameRecorder(page);
+ const frames = await scrollGesture(page, -1, 2);
+ expect(frames.loafSupported).toBe(false);
+});
+
+test('warm native transcript scroll metrics', async ({ promptRailWindow: page }) => {
+ test.skip(!PERF_ENABLED, 'manual same-build CDP A/B harness');
+ test.setTimeout(90_000);
+ await page.setViewportSize({ width: 1_000, height: 700 });
+ await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1);
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send('Performance.enable');
+ await prepareFrameRecorder(page);
+ await traverseFullHistoryAndReturnToTail(page);
+ await moveToTail(page);
+
+ // Warm Chromium, React and the transcript path in both directions before sampling.
+ await scrollGesture(page, -600, 120);
+ await scrollGesture(page, 600, 120);
+ await moveToTail(page);
+ await collectGarbage(cdp);
+ await page.evaluate(() => new Promise<void>((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ ));
+ const before = await performanceMetrics(cdp);
+ const frames = await scrollGesture(page, -600);
+ expect(
+ frames.loafSupported,
+ 'Chromium does not support the long-animation-frame release metric',
+ ).toBe(true);
+ const after = await performanceMetrics(cdp);
+ await collectGarbage(cdp);
+ const counters = await browserCounters(cdp);
+ const sourceTurns = await page.locator('[data-turn-source-count]').first()
+ .getAttribute('data-turn-source-count');
+ const mountedTurns = await page.locator('[data-turn-id]').count();
+ const domElements = await page.locator('*').count();
+ const sessionSwitchMs = await measureSessionSwitch(page);
+ const result = {
+ sourceTurns: Number(sourceTurns),
+ mountedTurns,
+ domElements,
+ taskMs: metricDelta(before, after, 'TaskDuration') * 1_000,
+ scriptMs: metricDelta(before, after, 'ScriptDuration') * 1_000,
+ layoutMs: metricDelta(before, after, 'LayoutDuration') * 1_000,
+ recalcStyleMs: metricDelta(before, after, 'RecalcStyleDuration') * 1_000,
+ heapBytes: counters.heapBytes,
+ nodes: counters.nodes,
+ documents: counters.documents,
+ jsEventListeners: counters.jsEventListeners,
+ frameCount: frames.intervals.length,
+ frameP95Ms: percentile(frames.intervals, 0.95),
+ frameP99Ms: percentile(frames.intervals, 0.99),
+ frameMaxMs: Math.max(...frames.intervals),
+ framesOver12_5Ms: frames.intervals.filter((duration) => duration > 12.5).length,
+ loafOver50Ms: frames.loafDurations.filter((duration) => duration > 50).length,
+ loafMaxMs: Math.max(0, ...frames.loafDurations),
+ loafSupported: frames.loafSupported,
+ sessionSwitchMs,
+ };
+ console.log(`TRANSCRIPT_PERF ${JSON.stringify(result)}`);
+});
+
+test('600+ Turn repeated paging keeps the active range on a memory plateau', async ({
+ promptRailWindow: page,
+}) => {
+ test.skip(!STRESS_ENABLED, 'manual 600+ Turn stress harness');
+ test.setTimeout(180_000);
+ await page.setViewportSize({ width: 1_000, height: 700 });
+ const cdp = await page.context().newCDPSession(page);
+ expect(PROMPT_RAIL_PROMPT_COUNT).toBeGreaterThanOrEqual(600);
+ const sweeps: StressSweep[] = [];
+ const heapGrowth: Array<HeapGrowth & { sweep: number }> = [];
+ const captureSample = async (sweep: number, iteration: number): Promise<StressSample> => {
+ await collectGarbage(cdp);
+ const counters = await browserCounters(cdp);
+ const sample = {
+ sweep,
+ iteration,
+ firstTurnId: await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'),
+ lastTurnId: await page.locator('[data-turn-id]').last().getAttribute('data-turn-id'),
+ mountedTurns: await page.locator('[data-turn-id]').count(),
+ ...counters,
+ };
+ return sample;
+ };
+
+ // Warm the paging composition once, independent of the fixture's history depth.
+ const latestFirstTurn = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id');
+ await page.evaluate((selector) => {
+ const root = document.querySelector<HTMLElement>(selector);
+ if (!root) throw new Error('the chat scroll container is missing');
+ root.scrollTop = 0;
+ root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
+ }, SCROLLER);
+ await expect.poll(async () =>
+ page.locator('[data-turn-id]').first().getAttribute('data-turn-id'),
+ ).not.toBe(latestFirstTurn);
+ await returnToLatest(page);
+ await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
+ .toHaveCount(1);
+
+ for (let sweep = 1; sweep <= 2; sweep += 1) {
+ const samples: StressSample[] = [await captureSample(sweep, 0)];
+ let successfulPages = 0;
+ for (let iteration = 1; iteration <= PROMPT_RAIL_PROMPT_COUNT; iteration += 1) {
+ const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id');
+ if (firstBefore?.endsWith('-1')) break;
+ await page.evaluate((selector) => {
+ const root = document.querySelector<HTMLElement>(selector);
+ if (!root) throw new Error('the chat scroll container is missing');
+ root.scrollTop = 0;
+ root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
+ }, SCROLLER);
+ await expect.poll(async () =>
+ page.locator('[data-turn-id]').first().getAttribute('data-turn-id'),
+ ).not.toBe(firstBefore);
+ successfulPages += 1;
+ if (iteration % 10 !== 0) continue;
+ samples.push(await captureSample(sweep, iteration));
+ }
+ await expect(page.locator('[data-turn-id]').first())
+ .toHaveAttribute('data-turn-id', 'turn-prompt-rail-1');
+ if (samples.at(-1)!.iteration !== successfulPages) {
+ samples.push(await captureSample(sweep, successfulPages));
+ }
+ sweeps.push({ sweep, successfulPages, samples });
+ const growth = { sweep, ...positiveHeapGrowth(samples) };
+ heapGrowth.push(growth);
+ console.log(`TRANSCRIPT_STRESS_SWEEP ${JSON.stringify({
+ sweep,
+ successfulPages,
+ samples,
+ heapGrowth: growth,
+ })}`);
+ expect(growth.endpointRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO);
+ expect(growth.projectedRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO);
+ if (sweep < 2) {
+ await returnToLatest(page);
+ await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
+ .toHaveCount(1);
+ }
+ }
+
+ const firstSweep = sweeps[0]!;
+ const secondSweep = sweeps[1]!;
+ expect(firstSweep.successfulPages).toBeGreaterThan(0);
+ expect(secondSweep.successfulPages).toBeGreaterThan(0);
+ const firstNodesByIteration = new Map(
+ firstSweep.samples.map((sample) => [sample.iteration, sample.nodes]),
+ );
+ let nodeMaxSecondToFirstRatio = 0;
+ for (const [index, sample] of secondSweep.samples.entries()) {
+ const firstNodes = index === secondSweep.samples.length - 1
+ ? firstSweep.samples.at(-1)!.nodes
+ : firstNodesByIteration.get(sample.iteration);
+ expect(firstNodes).toBeDefined();
+ const ratio = sample.nodes / firstNodes!;
+ nodeMaxSecondToFirstRatio = Math.max(nodeMaxSecondToFirstRatio, ratio);
+ }
+
+ const allSamples = sweeps.flatMap((sweep) => sweep.samples);
+ const mountedMax = Math.max(...allSamples.map((sample) => sample.mountedTurns));
+ console.log(`TRANSCRIPT_STRESS ${JSON.stringify({
+ fixtureTurns: PROMPT_RAIL_PROMPT_COUNT,
+ sweeps,
+ mountedMax,
+ nodeMin: Math.min(...allSamples.map((sample) => sample.nodes)),
+ nodeMax: Math.max(...allSamples.map((sample) => sample.nodes)),
+ nodeMaxSecondToFirstRatio,
+ heapGrowth,
+ })}`);
+ expect(mountedMax).toBeLessThanOrEqual(
+ transcriptContract.DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS,
+ );
+ expect(nodeMaxSecondToFirstRatio).toBeLessThanOrEqual(
+ 1 + SECONDARY_RESOURCE_GROWTH_RATIO,
+ );
+});
diff --git a/apps/desktop/e2e/onboarding-viewport.spec.ts b/apps/desktop/e2e/onboarding-viewport.spec.ts
index aec6a1683f..0605dd3b49 100644
--- a/apps/desktop/e2e/onboarding-viewport.spec.ts
+++ b/apps/desktop/e2e/onboarding-viewport.spec.ts
@@ -41,6 +41,7 @@ test('first-run onboarding stays within the chat viewport', async ({ onboardingW
surfaceBottom: surfaceRect.bottom,
viewportTop: scrollRect.top,
viewportBottom: scrollRect.bottom,
+ onboardingLayout: scrollContainer.dataset.makaOnboarding,
cardTop: cardRect.top,
cardBottom: cardRect.bottom,
};
@@ -50,6 +51,7 @@ test('first-run onboarding stays within the chat viewport', async ({ onboardingW
geometry.pageClientHeight,
);
expect(geometry.scrollHeight, JSON.stringify(geometry, null, 2)).toBe(geometry.clientHeight);
+ expect(geometry.onboardingLayout).toBe('true');
expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop);
expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom);
expect(geometry.cardTop).toBeGreaterThanOrEqual(geometry.viewportTop);
diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts
index 9596088c71..efbcec3dd0 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<void> {
+ await page.evaluate(() => {
+ const root = document.querySelector<HTMLElement>('[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<void> {
await page.evaluate((frames) => new Promise<void>((resolve) => {
const tick = (left: number) => {
@@ -116,15 +131,6 @@ function notifyTranscriptScrolled(page: Page): Promise<void> {
});
}
-async function loadPromptRailBeyondVirtualWindow(page: Page): Promise<void> {
- 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.
@@ -247,9 +253,11 @@ test('the first click of a session lands on its prompt and holds', async ({
// Bounded on both sides: below is the turn never arriving, above is it
// arriving and then being pulled off the top of the scrollport.
await expect
- .poll(async () => (await landing())?.offset, { message: 'the clicked prompt reaches the top' })
- .toBeGreaterThan(-24);
- expect((await landing())?.offset).toBeLessThan(24);
+ .poll(async () => {
+ const offset = (await landing())?.offset;
+ return offset === undefined ? Number.POSITIVE_INFINITY : Math.abs(offset);
+ }, { message: 'the clicked prompt reaches the top' })
+ .toBeLessThan(24);
expect((await landing())?.tickIsCurrent).toBe(true);
// And stays: turns keep resolving their content and remeasuring after the
@@ -262,43 +270,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<HTMLElement>('.maka-chat-message-list');
- const rows = transcript?.firstElementChild;
- const turn = document.querySelector<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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 +309,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<HTMLElement>('[data-chat-scroll-container="true"]');
- if (!root) throw new Error('the chat scroll container is missing');
- const mounted = [...document.querySelectorAll<HTMLElement>('[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..acc885b2d7 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);
@@ -353,6 +353,68 @@ test('a gesture a nested scroller consumed does not release the tail', async ({
expect(await scrollButtonOffered(page)).toBe(false);
});
+test('a nested scroller near the history boundary does not request an earlier range', async ({
+ promptRailWindow: page,
+}) => {
+ await page.setViewportSize({ width: 900, height: 1500 });
+ await waitForPaintedFrames(page, 6);
+ const metrics = await scrollMetrics(page);
+ expect(metrics.scrollTop).toBeLessThanOrEqual(Math.max(640, metrics.clientHeight * 2));
+ expect(metrics.distance).toBeLessThanOrEqual(4);
+
+ const nestedBefore = await page.evaluate((selector) => {
+ const root = document.querySelector<HTMLElement>(selector);
+ const list = root?.querySelector<HTMLElement>('.maka-chat-message-list');
+ if (!root || !list) throw new Error('the active transcript range is missing');
+ const box = document.createElement('div');
+ box.dataset.nestedHistoryScroller = 'true';
+ box.style.cssText = [
+ 'position:fixed',
+ 'top:160px',
+ 'left:160px',
+ 'width:240px',
+ 'height:120px',
+ 'overflow-y:auto',
+ 'z-index:9999',
+ ].join(';');
+ const filler = document.createElement('div');
+ filler.style.height = '2000px';
+ box.append(filler);
+ // A Turn uses `content-visibility:auto`, whose paint containment prevents
+ // a fixed descendant from reliably winning hit testing over sibling Turns
+ // on Linux/Xvfb. Keep the fixture inside the transcript event path without
+ // putting it inside the product containment boundary being tested.
+ list.append(box);
+ box.scrollTop = 600;
+ return box.scrollTop;
+ }, SCROLLER);
+
+ const nested = page.locator('[data-nested-history-scroller="true"]');
+ const box = await nested.boundingBox();
+ if (!box) throw new Error('the nested history scroller is not rendered');
+ const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
+ expect(await page.evaluate(({ x, y }) =>
+ document.elementFromPoint(x, y)?.closest('[data-nested-history-scroller="true"]') !== null,
+ point)).toBe(true);
+ await page.mouse.move(point.x, point.y);
+ await page.mouse.wheel(0, -400);
+ await waitForPaintedFrames(page);
+
+ const nestedAfter = await page.evaluate(() =>
+ document.querySelector<HTMLElement>('[data-nested-history-scroller="true"]')?.scrollTop ?? -1,
+ );
+ expect(nestedAfter).toBeLessThan(nestedBefore);
+ await page.evaluate(() => {
+ const list = document.querySelector('.maka-chat-message-list');
+ if (!list) throw new Error('the transcript content box is missing');
+ const grown = document.createElement('div');
+ grown.style.height = '600px';
+ list.append(grown);
+ });
+ await waitForPaintedFrames(page, 6);
+ expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
+});
+
test('the dock affordance returns the reader to the tail', async ({ window: page }) => {
test.slow();
await page.setViewportSize({ width: 900, height: 700 });
@@ -374,14 +436,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 +453,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<HTMLElement>('[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 +500,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 +532,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 +553,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 +580,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..22b6d176bd 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,275 @@ 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,
+ durableCoverage: 'complete',
+ 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,
+ durableCoverage: 'complete',
+ 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 an oversized latest Turn visible after bootstrap eviction', async () => {
+ const older = {
+ identity: 0,
+ message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' },
+ };
+ const latest = {
+ identity: 1,
+ message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'),
+ };
+ const bootstrapPage = transcriptPage('older', null, latest.identity);
+ const handle = runtimeHostSessionFixture({
+ snapshot: continuitySnapshot(),
+ transcript: Promise.resolve([]),
+ events: { async *[Symbol.asyncIterator]() {} },
+ transcriptBootstrap: {
+ throughSequence: latest.identity,
+ durableCoverage: 'complete',
+ overlayMessageCount: 0,
+ durable: bootstrapPage,
+ overlay: { ...transcriptPage('older', null, latest.identity), source: 'overlay' },
+ },
+ loadTranscriptOverlay: async () => [],
+ decodeTranscriptPage: async () => ({ messages: [older, latest], nextCursor: null }),
+ async close() {},
+ });
+
+ const replica = await DesktopTranscriptReplica.prepare(handle);
+
+ assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [latest.identity]);
+ assert.equal(replica.snapshot().hasOlder, true);
+});
+
+test('keeps an oversized latest Turn visible before a trailing session note', async () => {
+ const latest = {
+ identity: 0,
+ message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-0'),
+ };
+ const trailingNote = {
+ identity: 1,
+ message: {
+ type: 'system_note' as const,
+ id: 'mode-change-1',
+ ts: 2,
+ kind: 'mode_change' as const,
+ },
+ };
+ const bootstrapPage = {
+ ...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,
+ durableCoverage: 'complete',
+ overlayMessageCount: 0,
+ durable: bootstrapPage,
+ overlay: {
+ ...bootstrapPage,
+ source: 'overlay',
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
+ },
+ },
+ loadTranscriptOverlay: async () => [],
+ decodeTranscriptPage: async () => ({ messages: [latest, trailingNote], nextCursor: null }),
+ async close() {},
+ });
+
+ const replica = await DesktopTranscriptReplica.prepare(handle);
+
+ 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,
+ durableCoverage: 'complete',
+ 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}`),
+ message: {
+ ...assistantMessage(String(sequence), `assistant-${sequence}`),
+ turnId: `turn-${sequence}`,
+ },
}));
const page = (nextCursor: string | null) => ({
kind: 'page' as const,
@@ -219,6 +485,8 @@ test('keeps a bounded contiguous window while moving between history and the tai
throughSequence: 4,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor,
});
const bootstrapPage = page('older');
@@ -239,19 +507,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 +532,48 @@ 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,
+ durableCoverage: 'complete',
+ 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 +584,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) => ({
@@ -281,6 +598,8 @@ test('delivers a mid-session tail append even while a history window is resident
throughSequence: 4,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor,
});
const bootstrapPage = page('older');
@@ -305,17 +624,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);
@@ -327,6 +650,100 @@ test('delivers a mid-session tail append even while a history window is resident
assert.equal(replica.durableThrough, 5);
});
+test('keeps an oversized streaming Turn visible when its overlay settles', async () => {
+ const older = {
+ identity: 0,
+ message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' },
+ };
+ const latest = {
+ identity: 1,
+ message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'),
+ };
+ const bootstrapPage = transcriptPage('older', null, older.identity);
+ const newerPage = {
+ ...transcriptPage('newer', null, latest.identity),
+ rangeBoundarySequence: latest.identity,
+ protectedTurnSequence: latest.identity,
+ };
+ const handle = runtimeHostSessionFixture({
+ snapshot: continuitySnapshot(),
+ transcript: Promise.resolve([]),
+ events: { async *[Symbol.asyncIterator]() {} },
+ transcriptBootstrap: {
+ throughSequence: older.identity,
+ durableCoverage: 'complete',
+ overlayMessageCount: 1,
+ durable: bootstrapPage,
+ overlay: { ...transcriptPage('older', null, older.identity), source: 'overlay' },
+ },
+ loadTranscriptOverlay: async () => [latest.message],
+ decodeTranscriptPage: async (page) => page === bootstrapPage
+ ? { messages: [older], nextCursor: null }
+ : { messages: [latest], nextCursor: null },
+ loadTranscriptPage: async () => newerPage,
+ async close() {},
+ });
+ const replica = await DesktopTranscriptReplica.prepare(handle);
+ assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), [latest.message.id]);
+
+ await replica.advance(latest.identity);
+
+ const snapshot = replica.snapshot();
+ assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [latest.identity]);
+ assert.deepEqual(snapshot.overlay, []);
+});
+
+test('keeps an oversized settled Turn visible before a trailing session note', async () => {
+ const older = {
+ identity: 0,
+ message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' },
+ };
+ const latest = {
+ identity: 1,
+ message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'),
+ };
+ const trailingNote = {
+ identity: 2,
+ message: {
+ type: 'system_note' as const,
+ id: 'mode-change-2',
+ ts: 3,
+ kind: 'mode_change' as const,
+ },
+ };
+ const bootstrapPage = transcriptPage('older', null, older.identity);
+ const newerPage = {
+ ...transcriptPage('newer', null, trailingNote.identity),
+ rangeBoundarySequence: trailingNote.identity,
+ protectedTurnSequence: latest.identity,
+ };
+ const handle = runtimeHostSessionFixture({
+ snapshot: continuitySnapshot(),
+ transcript: Promise.resolve([]),
+ events: { async *[Symbol.asyncIterator]() {} },
+ transcriptBootstrap: {
+ throughSequence: older.identity,
+ durableCoverage: 'complete',
+ overlayMessageCount: 1,
+ durable: bootstrapPage,
+ overlay: { ...bootstrapPage, source: 'overlay' },
+ },
+ loadTranscriptOverlay: async () => [latest.message],
+ decodeTranscriptPage: async (page) => page === bootstrapPage
+ ? { messages: [older], nextCursor: null }
+ : { messages: [latest, trailingNote], nextCursor: null },
+ loadTranscriptPage: async () => newerPage,
+ async close() {},
+ });
+ const replica = await DesktopTranscriptReplica.prepare(handle);
+
+ await replica.advance(trailingNote.identity);
+
+ const snapshot = replica.snapshot();
+ assert.ok(snapshot.durable.some(({ sequence }) => sequence === latest.identity));
+ 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
@@ -335,7 +752,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) => ({
@@ -346,6 +766,8 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight'
throughSequence: 4,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor,
});
const bootstrapPage = page('older');
@@ -376,7 +798,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 +811,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);
@@ -430,6 +856,8 @@ test('does not resurrect a discarded replica when a history load is in flight',
throughSequence: 4,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor,
});
const bootstrapPage = page('older');
@@ -503,6 +931,8 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is
throughSequence,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor,
});
const bootstrapPage = page(null, 4);
@@ -849,8 +1279,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 +1304,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();
});
@@ -962,6 +1398,8 @@ function transcriptPage(
throughSequence,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor,
};
}
diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts
index 4318b9d004..0b82aef122 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts
@@ -210,6 +210,8 @@ function emptyTranscriptPage(sessionId: string, source: 'durable' | 'overlay') {
throughSequence: null,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
};
}
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 4af7966a1b..2bad979733 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
@@ -586,6 +586,8 @@ test('fences transcript range failures across same-source replica recovery', asy
throughSequence: 1,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: 'older',
};
return runtimeHostSessionFixture({
@@ -726,6 +728,8 @@ test('broadcasts durable admission and transcript changes from the same message'
throughSequence: 0,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
}),
decodeTranscriptPage: async () => ({
@@ -898,6 +902,8 @@ test('finishes transcript open and replays a stale range request after replaceme
throughSequence: 0,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: 'older',
},
overlay: {
@@ -908,6 +914,8 @@ test('finishes transcript open and replays a stale range request after replaceme
throughSequence: null,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
},
},
@@ -927,6 +935,8 @@ test('finishes transcript open and replays a stale range request after replaceme
throughSequence: input.throughSequence,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
};
},
@@ -1070,6 +1080,8 @@ test('coalesces transcript changes into one bounded delta while renderer deliver
throughSequence: input.throughSequence,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
}),
decodeTranscriptPage: async (page) => {
@@ -1170,6 +1182,8 @@ test('does not let one backpressured transcript consumer block another', async (
throughSequence: input.throughSequence,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
}),
decodeTranscriptPage: async (page) => {
@@ -1275,6 +1289,8 @@ test('keeps a transcript consumer available after a delivery fails', async () =>
throughSequence: input.throughSequence,
rawBytes: 1,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
}),
decodeTranscriptPage: async (page) => ({
@@ -1781,6 +1797,8 @@ test("recovers when transcript paging loses the active subscription", async () =
throughSequence: 0,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
};
},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts
index 6ea30bb2d9..8a22cd60a3 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts
@@ -74,6 +74,8 @@ function emptyPage(sessionId: string, source: 'durable' | 'overlay'): SessionTra
throughSequence: null,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
};
}
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<typeof ChatView>[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</);
});
@@ -146,7 +146,7 @@ describe('single live-turn handoff', () => {
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..0730da8906 100644
--- a/apps/desktop/src/main/desktop-transcript-replica.ts
+++ b/apps/desktop/src/main/desktop-transcript-replica.ts
@@ -24,11 +24,14 @@ import {
type RuntimeHostSessionProjectionSeed,
} from '@maka/runtime-host/adapter';
import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client';
-import type { SessionTranscriptPage } from '@maka/runtime-host/protocol';
import {
- DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES,
+ SESSION_TRANSCRIPT_RANGE_MAX_BYTES,
+ type SessionTranscriptPage,
+} from '@maka/runtime-host/protocol';
+import {
+ DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS,
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 +39,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 +83,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,10 +114,12 @@ 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;
+ this.#maxMessageBytes = options.maxMessageBytes ?? SESSION_TRANSCRIPT_RANGE_MAX_BYTES;
this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined);
this.#onChange = options.onChange ?? (() => undefined);
this.#durableThrough = handle.transcriptBootstrap.throughSequence;
@@ -135,7 +142,13 @@ export class DesktopTranscriptReplica {
replica.#installDurable(durable.messages);
replica.#hasOlder = durable.nextCursor !== null;
});
- replica.#evictToBudget();
+ replica.#evictToBudget(
+ undefined,
+ 'oldest',
+ handle.transcriptBootstrap.durable.protectedTurnSequence ??
+ replica.#durableThrough ??
+ undefined,
+ );
if (replica.#overlayBytes > replica.#maxOverlayBytes) {
throw new RangeError('Desktop transcript overlay exceeds the session cache limit');
}
@@ -248,7 +261,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 +320,11 @@ 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',
+ loadTail ? (page.protectedTurnSequence ?? sequence) : sequence,
+ ),
);
this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences);
});
@@ -413,7 +434,11 @@ export class DesktopTranscriptReplica {
expectedSequence = decoded.messages.at(-1)!.identity + 1;
}
const completedOverlayMessageIds = this.#installDurable(decoded.messages);
- const evictedDurableSequences = this.#evictToBudget();
+ const evictedDurableSequences = this.#evictToBudget(
+ undefined,
+ 'oldest',
+ page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity,
+ );
this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences);
cursor = decoded.nextCursor;
});
@@ -525,19 +550,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<string, number[]>();
+ 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 +711,16 @@ function encodedMessageBytes(message: StoredMessage): number {
return Buffer.byteLength(JSON.stringify(message), 'utf8');
}
+function residentTurnKey(entry: ResidentMessage): string {
+ const turnId = messageTurnId(entry.message);
+ return turnId === undefined ? `sequence:${entry.sequence}` : `turn:${turnId}`;
+}
+
+function messageTurnId(message: StoredMessage): string | undefined {
+ const turnId = 'turnId' in message ? message.turnId : undefined;
+ return typeof turnId === 'string' ? turnId : undefined;
+}
+
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..d38ecfaf78 100644
--- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts
+++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts
@@ -35,8 +35,10 @@ 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. */
-export const PROMPT_RAIL_PROMPT_COUNT = 120;
+/** Exceeds both the 64-tick rail and the bounded active transcript range. */
+export const PROMPT_RAIL_PROMPT_COUNT = process.env.MAKA_TRANSCRIPT_STRESS === '1'
+ ? 640
+ : 120;
export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-';
export const LONG_SIDEBAR_SESSION_COUNT = 60;
export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project';
diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts
index c14addfd01..5eef5d7571 100644
--- a/apps/desktop/src/main/runtime-host-session-observer.ts
+++ b/apps/desktop/src/main/runtime-host-session-observer.ts
@@ -26,19 +26,19 @@ import {
isRuntimeHostTerminalTurn as isTerminalTurn,
projectRuntimeHostInteractionRequest,
} from "@maka/runtime-host/adapter";
-import type {
- InteractionAnsweredSnapshot,
- InteractionPendingSnapshot,
- SessionDomainChange,
- SessionContinuitySnapshot,
- SubscriptionFrame,
+import {
+ SESSION_TRANSCRIPT_RANGE_MAX_BYTES,
+ type InteractionAnsweredSnapshot,
+ type InteractionPendingSnapshot,
+ type SessionDomainChange,
+ type SessionContinuitySnapshot,
+ type SubscriptionFrame,
} from "@maka/runtime-host/protocol";
import type { DesktopRuntimeHostClient } from "./runtime-host-client.js";
import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client";
import {
DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES,
DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES,
- DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES,
DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES,
type DesktopTranscriptBatch,
type DesktopTranscriptBatchPayload,
@@ -1460,7 +1460,7 @@ function requireTranscriptRangeBytes(value: number): number {
function resetDeliveryWorkingSetBytes(residentBytes: number): number {
return (
- Math.min(residentBytes, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES) +
+ Math.min(residentBytes, SESSION_TRANSCRIPT_RANGE_MAX_BYTES) +
Math.min(
residentBytes,
(TRANSCRIPT_DELIVERY_WINDOW * 2 + 1) * DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES,
diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts
index 51b1880233..41f00924c7 100644
--- a/apps/desktop/src/preload/transcript-contract.ts
+++ b/apps/desktop/src/preload/transcript-contract.ts
@@ -19,10 +19,9 @@
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;
export interface DesktopTranscriptFragment {
readonly source: 'durable' | 'overlay';
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index f6bded74bc..b899ce429f 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();
}
@@ -2902,6 +2902,7 @@ function AppShellContent({
// authority there, and the composer never remounts for any of
// them — its contenteditable DOM carries the live draft.
scrollOwner="host"
+ data-maka-onboarding={showOnboardingHero ? 'true' : undefined}
scrollToBottomLabel={
desktopConversationCopy.actions.scrollMainToBottom
}
@@ -3098,7 +3099,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> | void;
+ onLoadEarlierHistory: (anchorTurnId?: string) => Promise<void> | void;
onReturnToLatestHistory: () => Promise<void> | 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<void>;
waitForDurableMessage(messageId: string, timeoutMs: number): Promise<boolean>;
- loadBefore(maxBytes?: number): Promise<void>;
+ loadBefore(maxBytes?: number, anchorTurnId?: string): Promise<void>;
loadAround(sequence: number): Promise<void>;
loadLatest(): Promise<void>;
reload(): Promise<void>;
@@ -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<number, StoredRecord>();
readonly #overlay = new Map<string, OverlayRecord>();
+ readonly #durableOrder: number[] = [];
+ readonly #overlayOrder: string[] = [];
readonly #pending = new Map<string, PendingRecord>();
#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<boolean> {
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<StoredMessage>(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<T>(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<T>(
+ 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<T>(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/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css
index 6f1c9a6f4a..a096d564a0 100644
--- a/apps/desktop/src/renderer/styles/onboarding.css
+++ b/apps/desktop/src/renderer/styles/onboarding.css
@@ -56,12 +56,17 @@
an empty flex spacer before its empty state so ordinary chat heroes settle
low in the message flow; here that spacer gives the full-height onboarding
surface only half of the available block size. The surface then overflows
- ChatLayout and its self-scroll root exposes a phantom page scrollbar. */
-.maka-chat-layout:has(.maka-onboarding-surface) {
+ ChatLayout and its self-scroll root exposes a phantom page scrollbar.
+
+ AppShell already owns `showOnboardingHero` and projects it onto ChatLayout.
+ Keep that direct composition state here: a descendant `:has()` made every
+ prompt-rail `aria-current` change invalidate the entire chat subtree during
+ scrolling. */
+.maka-chat-layout[data-maka-onboarding='true'] {
overflow-y: hidden;
}
-.maka-chat-layout:has(.maka-onboarding-surface) > :first-child {
+.maka-chat-layout[data-maka-onboarding='true'] > :first-child {
flex: 1 1 auto;
padding-block-end: 0;
overflow: hidden;
@@ -73,9 +78,8 @@
/* ChatLayout always paints its frosted composer dock, even when the Composer
itself is hidden. Onboarding owns the whole empty surface, so that dock has
- no content to support and would blur the card actions behind it. Derive the
- opt-out from the mounted surface instead of duplicating onboarding state. */
-.maka-chat-layout:has(.maka-onboarding-surface) > :last-child {
+ no content to support and would blur the card actions behind it. */
+.maka-chat-layout[data-maka-onboarding='true'] > :last-child {
display: none;
}
diff --git a/apps/desktop/stories/onboarding.stories.tsx b/apps/desktop/stories/onboarding.stories.tsx
index 09efc25f79..886e3e2cf8 100644
--- a/apps/desktop/stories/onboarding.stories.tsx
+++ b/apps/desktop/stories/onboarding.stories.tsx
@@ -85,7 +85,11 @@ function DetailPane(props: { children?: ReactNode }) {
>
<div className="maka-detail-with-artifacts">
<div className="mainColumn" data-home-surface="true">
- <ChatSurfaceLayout scrollOwner="host" composer={null}>
+ <ChatSurfaceLayout
+ scrollOwner="host"
+ composer={null}
+ data-maka-onboarding={props.children === undefined ? undefined : 'true'}
+ >
<ChatView messages={[]} scrollBehavior="smooth" onNew={() => undefined} emptyOverride={emptyOverride} />
</ChatSurfaceLayout>
</div>
diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts
index f6c0715795..3abca22312 100644
--- a/packages/runtime-host/src/__tests__/connection-session.test.ts
+++ b/packages/runtime-host/src/__tests__/connection-session.test.ts
@@ -178,6 +178,8 @@ test('transcript pages are serialized per connection before their responses are
throughSequence: input.throughSequence,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
},
};
@@ -1651,6 +1653,8 @@ function transcriptBootstrapFor(sessionId: string) {
data: contents.toString('base64'),
},
],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
},
overlay: {
@@ -1661,6 +1665,8 @@ function transcriptBootstrapFor(sessionId: string) {
throughSequence: 0,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
},
};
diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
index 6ca64650b9..bca8eaf612 100644
--- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
+++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
@@ -544,6 +544,90 @@ test('decodes one bounded page without walking the remaining transcript', async
assert.deepEqual(requests, []);
});
+test('assembles the complete edge Turn while paging newer transcript', async () => {
+ const prompt = {
+ type: 'user' as const,
+ id: 'user-1',
+ turnId: 'turn-1',
+ ts: 1,
+ text: 'prompt',
+ };
+ const answer = {
+ type: 'assistant' as const,
+ id: 'assistant-1',
+ turnId: 'turn-1',
+ ts: 2,
+ text: 'answer',
+ modelId: 'model-1',
+ };
+ const promptBytes = Buffer.from(JSON.stringify(prompt), 'utf8');
+ const answerBytes = Buffer.from(JSON.stringify(answer), 'utf8');
+ const requests: string[] = [];
+ const initial: SessionTranscriptPage = {
+ ...transcriptPage({
+ rawBytes: promptBytes.byteLength,
+ fragments: [
+ {
+ kind: 'durable',
+ sequence: 0,
+ byteOffset: 0,
+ totalBytes: promptBytes.byteLength,
+ payloadDigest: null,
+ data: promptBytes.toString('base64'),
+ },
+ ],
+ nextCursor: 'answer',
+ }),
+ direction: 'newer',
+ throughSequence: 1,
+ rangeBoundarySequence: 1,
+ protectedTurnSequence: 1,
+ };
+ const subscription = new ClientSessionSubscription(
+ openResult('host-1', 'subscription-newer-turn', {
+ throughSequence: 1,
+ durableCoverage: 'complete',
+ overlayMessageCount: 0,
+ durable: initial,
+ overlay: { ...transcriptPage({ source: 'overlay' }), throughSequence: 1 },
+ }),
+ async () => undefined,
+ async (input) => {
+ requests.push(input.cursor!);
+ return {
+ ...transcriptPage({
+ rawBytes: answerBytes.byteLength,
+ fragments: [
+ {
+ kind: 'durable',
+ sequence: 1,
+ byteOffset: 0,
+ totalBytes: answerBytes.byteLength,
+ payloadDigest: null,
+ data: answerBytes.toString('base64'),
+ },
+ ],
+ }),
+ direction: 'newer',
+ throughSequence: 1,
+ rangeBoundarySequence: 1,
+ protectedTurnSequence: 1,
+ };
+ },
+ );
+
+ const decoded = await subscription.decodeTranscriptPage(initial, decodeStoredMessage);
+
+ assert.deepEqual(
+ decoded.messages.map(({ identity, message }) => [identity, message.id]),
+ [
+ [0, 'user-1'],
+ [1, 'assistant-1'],
+ ],
+ );
+ assert.deepEqual(requests, ['answer']);
+});
+
test('loads and releases only the active overlay', async () => {
const overlay = {
type: 'user' as const,
@@ -1409,6 +1493,8 @@ function transcriptPage(
throughSequence: 0,
rawBytes: options.rawBytes ?? 0,
fragments: options.fragments ?? [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: options.nextCursor ?? null,
};
}
diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts
index 9dd2b10fcd..e3fd95a6ba 100644
--- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts
+++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts
@@ -19,7 +19,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import type { StoredMessage } from '@maka/core/session';
+import {
+ decodeStoredMessage as decodePersistedStoredMessage,
+ type StoredMessage,
+} from '@maka/core/session';
+import { markPersisted } from '@maka/core/persisted-value';
+import { ClientSessionSubscription } from '../client/session-subscription.js';
+import { SESSION_CONTINUITY_SCHEMA_VERSION } from '../protocol/index.js';
import {
createSessionTranscriptBootstrap,
prepareSessionTranscriptOverlay,
@@ -67,6 +73,8 @@ test('reads newly durable messages forward from an announced watermark', async (
),
[2, 3],
);
+ assert.equal(page.rangeBoundarySequence, 3);
+ assert.equal(page.protectedTurnSequence, 3);
assert.equal(page.nextCursor, null);
});
@@ -282,6 +290,295 @@ test('keeps a durable continuation when overlay bytes reduce the bootstrap budge
assert.ok(bootstrap.durable.nextCursor);
});
+test('opens the complete latest Turn when bootstrap starts inside its assistant', async () => {
+ const prompt = { ...userMessage(0, 'hello'), turnId: 'turn-1' };
+ const assistant = {
+ ...assistantMessage(1),
+ turnId: 'turn-1',
+ text: 'x'.repeat(20 * 1024),
+ };
+ const reader = transcriptReader([prompt, assistant]);
+ const { bootstrap, state } = await createSessionTranscriptBootstrap({
+ reader,
+ sessionId: 'session-1',
+ subscriptionId: 'subscription-1',
+ throughSequence: 1,
+ rootTurn: {
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ runId: 'run-1',
+ status: 'running',
+ },
+ activeAssistantStreams: [],
+ maxBytes: 16 * 1024,
+ projection: 'owner',
+ });
+
+ assert.equal(bootstrap.durable.rangeBoundarySequence, 0);
+ assert.equal(bootstrap.durable.protectedTurnSequence, 1);
+ assert.ok(bootstrap.durable.nextCursor);
+ const subscription = new ClientSessionSubscription(
+ {
+ hostEpoch: 'host-1',
+ subscriptionId: 'subscription-1',
+ nextSequence: 1,
+ activeAssistantStreams: [],
+ transcript: bootstrap,
+ snapshot: {
+ schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION,
+ session: {
+ sessionId: 'session-1',
+ metadataRevision: 1,
+ status: 'running',
+ createdAt: 1,
+ isArchived: false,
+ },
+ projectionRevision: 1,
+ rootTurn: {
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ runId: 'run-1',
+ status: 'running',
+ },
+ goal: null,
+ queue: { hostEpoch: 'host-1', queueRevision: 1, steering: [], followup: [] },
+ interactions: { pending: [] },
+ },
+ },
+ async () => undefined,
+ (request) => readSessionTranscriptPage({ reader, state, request }),
+ );
+ const decodeStoredMessage = (value: unknown): StoredMessage =>
+ decodePersistedStoredMessage(markPersisted<StoredMessage>(value));
+
+ const decoded = await subscription.decodeTranscriptPage(bootstrap.durable, decodeStoredMessage);
+
+ assert.deepEqual(decoded.messages, [
+ { identity: 0, message: prompt },
+ { identity: 1, message: assistant },
+ ]);
+ assert.equal(decoded.nextCursor, null);
+});
+
+test('rejects a latest Turn that exceeds the Host range message bound', async () => {
+ const durable = Array.from({ length: 257 }, (_, index) => ({
+ ...assistantMessage(index),
+ turnId: 'turn-1',
+ }));
+
+ await assert.rejects(
+ createSessionTranscriptBootstrap({
+ reader: transcriptReader(durable),
+ sessionId: 'session-1',
+ subscriptionId: 'subscription-1',
+ throughSequence: durable.length - 1,
+ rootTurn: {
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ runId: 'run-1',
+ status: 'running',
+ },
+ activeAssistantStreams: [],
+ maxBytes: 16 * 1024,
+ projection: 'owner',
+ }),
+ /Turn range exceeds its capacity limit/,
+ );
+});
+
+test('admits a latest Turn exactly at the Host range message bound', async () => {
+ const durable: StoredMessage[] = [
+ { ...assistantMessage(0), turnId: 'turn-before' },
+ ...Array.from({ length: 256 }, (_, index) => ({
+ ...assistantMessage(index + 1),
+ turnId: 'turn-latest',
+ })),
+ ];
+
+ for (const projection of ['owner', 'shared'] as const) {
+ const { bootstrap } = await createSessionTranscriptBootstrap({
+ reader: transcriptReader(durable),
+ sessionId: 'session-1',
+ subscriptionId: `subscription-${projection}`,
+ throughSequence: durable.length - 1,
+ rootTurn: {
+ sessionId: 'session-1',
+ turnId: 'turn-before',
+ runId: 'run-1',
+ status: 'running',
+ },
+ activeAssistantStreams: [],
+ maxBytes: 16 * 1024,
+ projection,
+ });
+
+ assert.equal(bootstrap.durable.rangeBoundarySequence, 1);
+ assert.equal(bootstrap.durable.protectedTurnSequence, durable.length - 1);
+ }
+});
+
+test('admits a forward Turn exactly at the Host range message bound', async () => {
+ for (const projection of ['owner', 'shared'] as const) {
+ const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }];
+ const reader = transcriptReader(durable);
+ const subscriptionId = `subscription-${projection}`;
+ const { state } = await createSessionTranscriptBootstrap({
+ reader,
+ sessionId: 'session-1',
+ subscriptionId,
+ throughSequence: 0,
+ rootTurn: null,
+ activeAssistantStreams: [],
+ maxBytes: 16 * 1024,
+ projection,
+ });
+ durable.push(
+ ...Array.from({ length: 256 }, (_, index) => ({
+ ...assistantMessage(index + 1),
+ turnId: 'turn-page',
+ })),
+ { ...assistantMessage(257), turnId: 'turn-after' },
+ );
+ assert.equal(updateSubscriberTranscriptHighWater(state, durable.length - 1), true);
+
+ const page = await readSessionTranscriptPage({
+ reader,
+ state,
+ request: {
+ subscriptionId,
+ source: 'durable',
+ direction: 'newer',
+ throughSequence: durable.length - 1,
+ cursor: null,
+ anchorSequence: 0,
+ maxBytes: 512 * 1024,
+ },
+ });
+
+ assert.equal(page.rangeBoundarySequence, 256);
+ assert.equal(page.protectedTurnSequence, 256);
+ assert.ok(page.nextCursor);
+ }
+});
+
+test('protects the latest Turn when a forward page ends in a session note', async () => {
+ const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }];
+ const reader = transcriptReader(durable);
+ const { state } = await createSessionTranscriptBootstrap({
+ reader,
+ sessionId: 'session-1',
+ subscriptionId: 'subscription-1',
+ throughSequence: 0,
+ rootTurn: null,
+ activeAssistantStreams: [],
+ maxBytes: 16 * 1024,
+ projection: 'owner',
+ });
+ durable.push(
+ { ...assistantMessage(1), turnId: 'turn-latest' },
+ { type: 'system_note', id: 'mode-change-2', ts: 3, kind: 'mode_change' },
+ );
+ assert.equal(updateSubscriberTranscriptHighWater(state, 2), true);
+
+ const page = await readSessionTranscriptPage({
+ reader,
+ state,
+ request: {
+ subscriptionId: 'subscription-1',
+ source: 'durable',
+ direction: 'newer',
+ throughSequence: 2,
+ cursor: null,
+ anchorSequence: 0,
+ maxBytes: 512 * 1024,
+ },
+ });
+
+ assert.equal(page.rangeBoundarySequence, 2);
+ assert.equal(page.protectedTurnSequence, 1);
+});
+
+test('shared paging skips a full hidden storage batch before a visible message', async () => {
+ const hidden = Array.from(
+ { length: 257 },
+ (_, index): StoredMessage => ({
+ type: 'permission_decision',
+ id: `permission-${index}`,
+ turnId: `turn-${index}`,
+ ts: index + 1,
+ toolUseId: `tool-${index}`,
+ toolName: 'write_file',
+ decision: 'allow',
+ }),
+ );
+ const visible = userMessage(hidden.length, 'visible');
+ const { bootstrap } = await createSessionTranscriptBootstrap({
+ reader: transcriptReader([...hidden, visible]),
+ sessionId: 'session-1',
+ subscriptionId: 'subscription-1',
+ throughSequence: hidden.length,
+ rootTurn: null,
+ activeAssistantStreams: [],
+ maxBytes: 16 * 1024,
+ projection: 'shared',
+ });
+
+ assert.deepEqual(
+ decodeBootstrap(bootstrap.durable).map(({ id }) => id),
+ [visible.id],
+ );
+ assert.equal(bootstrap.durable.nextCursor, null);
+});
+
+test('shared range edges cross a hidden storage batch between visible messages', async () => {
+ const durable: StoredMessage[] = [
+ userMessage(0, 'before'),
+ ...Array.from(
+ { length: 257 },
+ (_, index): StoredMessage => ({
+ type: 'permission_decision',
+ id: `permission-between-${index}`,
+ turnId: `turn-hidden-${index}`,
+ ts: index + 2,
+ toolUseId: `tool-between-${index}`,
+ toolName: 'write_file',
+ decision: 'allow',
+ }),
+ ),
+ userMessage(258, 'after'),
+ ];
+ const reader = transcriptReader(durable);
+ const { bootstrap, state } = await createSessionTranscriptBootstrap({
+ reader,
+ sessionId: 'session-1',
+ subscriptionId: 'subscription-1',
+ throughSequence: durable.length - 1,
+ rootTurn: null,
+ activeAssistantStreams: [],
+ maxBytes: 16 * 1024,
+ projection: 'shared',
+ });
+
+ assert.equal(bootstrap.durable.rangeBoundarySequence, 0);
+ assert.equal(bootstrap.durable.protectedTurnSequence, 258);
+
+ const forward = await readSessionTranscriptPage({
+ reader,
+ state,
+ request: {
+ subscriptionId: 'subscription-1',
+ source: 'durable',
+ direction: 'newer',
+ throughSequence: durable.length - 1,
+ cursor: null,
+ anchorSequence: null,
+ maxBytes: 512 * 1024,
+ },
+ });
+ assert.equal(forward.rangeBoundarySequence, 258);
+ assert.equal(forward.protectedTurnSequence, 258);
+});
+
test('shrinks the raw bootstrap until it fits its aggregate encoded budget', async () => {
const durable = Array.from({ length: 100 }, (_, index) => userMessage(index, `message-${index}`));
const { bootstrap } = await createSessionTranscriptBootstrap({
diff --git a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts
index 78e73ce6b4..da33e1ce1f 100644
--- a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts
@@ -58,6 +58,8 @@ const page = {
data: Buffer.from('test').toString('base64'),
},
],
+ rangeBoundarySequence: 2,
+ protectedTurnSequence: 2,
nextCursor: 'opaque-cursor',
};
@@ -81,6 +83,8 @@ test('Session transcript protocol accepts bounded correlated pages and bootstrap
throughSequence: 3,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
},
};
@@ -162,6 +166,27 @@ test('Session transcript protocol rejects malformed and uncorrelated values', ()
() => decodeSessionTranscriptPageInput({ ...input, cursor: 'cursor', anchorSequence: 2 }),
isProtocolError,
);
+ assert.throws(
+ () => decodeSessionTranscriptPage({ ...page, rangeBoundarySequence: 4 }),
+ isProtocolError,
+ );
+ assert.throws(
+ () => decodeSessionTranscriptPage({ ...page, protectedTurnSequence: 4 }),
+ isProtocolError,
+ );
+ assert.throws(
+ () =>
+ decodeSessionTranscriptPage({
+ ...page,
+ source: 'overlay',
+ rawBytes: 0,
+ fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: 2,
+ nextCursor: null,
+ }),
+ isProtocolError,
+ );
assert.throws(
() =>
decodeSessionTranscriptPage({
diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts
index 3a61369a59..a458dc9743 100644
--- a/packages/runtime-host/src/client/session-subscription.ts
+++ b/packages/runtime-host/src/client/session-subscription.ts
@@ -23,6 +23,8 @@ import {
type SessionAssistantStreamIdentity,
type SessionContinuitySnapshot,
SESSION_TRANSCRIPT_PAGE_MAX_BYTES,
+ SESSION_TRANSCRIPT_RANGE_MAX_BYTES,
+ SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES,
type SubscriptionFrame,
type SubscriptionOpenResult,
type SessionTranscriptBootstrap,
@@ -235,7 +237,15 @@ export class ClientSessionSubscription
try {
assembler.accept(page.fragments);
let cursor = page.nextCursor;
- while (assembler.continuationBytes !== null) {
+ let rangeBytes = page.fragments.reduce((total, fragment) => total + fragment.totalBytes, 0);
+ const rangeIdentities = new Set(
+ page.fragments.map((fragment) =>
+ fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex,
+ ),
+ );
+ let reachedBoundary =
+ page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence);
+ while (assembler.continuationBytes !== null || !reachedBoundary) {
if (cursor === null) {
throw new RuntimeHostSubscriptionError(
'correlation_changed',
@@ -249,7 +259,10 @@ export class ClientSessionSubscription
throughSequence: page.throughSequence,
cursor,
anchorSequence: null,
- maxBytes: Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes),
+ maxBytes:
+ assembler.continuationBytes === null
+ ? SESSION_TRANSCRIPT_PAGE_MAX_BYTES
+ : Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes),
});
if (continuation.nextCursor === requestedCursor) {
throw new RuntimeHostSubscriptionError(
@@ -257,7 +270,22 @@ export class ClientSessionSubscription
'Session transcript cursor did not advance',
);
}
+ for (const fragment of continuation.fragments) {
+ const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex;
+ if (!rangeIdentities.has(identity)) {
+ rangeIdentities.add(identity);
+ rangeBytes += fragment.totalBytes;
+ }
+ }
+ if (
+ rangeBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES ||
+ rangeIdentities.size > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES
+ ) {
+ throw new RangeError('Session transcript range exceeds the local capacity limit');
+ }
assembler.accept(continuation.fragments);
+ reachedBoundary =
+ page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence);
cursor = continuation.nextCursor;
}
return {
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 7d9f06ddf4..8b03815a81 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 72 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 73 as const;
+// 73: Transcript pages carry a Host-owned Turn range boundary. Older peers
+// cannot preserve both the complete edge Turn and the bounded projection.
// 71: Session Guests can submit durable exact Turn access requests and Owners
// can decide them. Older peers do not understand this execution-authority flow.
// 70: Session Guest connections receive resource-scoped shared catalog and
diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts
index d54f791491..e50b0d03e9 100644
--- a/packages/runtime-host/src/protocol/session-transcript.ts
+++ b/packages/runtime-host/src/protocol/session-transcript.ts
@@ -32,6 +32,8 @@ import { defineOperation } from './operation-spec.js';
export const SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES = 16 * 1024;
export const SESSION_TRANSCRIPT_PAGE_MAX_BYTES = 512 * 1024;
export const SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES = 256;
+export const SESSION_TRANSCRIPT_RANGE_MAX_BYTES = 16 * 1024 * 1024;
+export const SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES = SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES;
export const SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES = 4_096;
export const SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES = 744 * 1024;
export const SESSION_TRANSCRIPT_CURSOR_MAX_BYTES = 1024;
@@ -64,6 +66,10 @@ export interface SessionTranscriptPage {
readonly throughSequence: number | null;
readonly rawBytes: number;
readonly fragments: readonly SessionTranscriptFragment[];
+ /** Host-selected far edge that the client must assemble before publishing this range. */
+ readonly rangeBoundarySequence: number | null;
+ /** Host-selected Turn identity that bounded consumers must retain while trimming this range. */
+ readonly protectedTurnSequence: number | null;
readonly nextCursor: string | null;
}
@@ -245,6 +251,8 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa
'throughSequence',
'rawBytes',
'fragments',
+ 'rangeBoundarySequence',
+ 'protectedTurnSequence',
'nextCursor',
]);
if (result.kind !== 'page') throw invalidProtocolFrame('Invalid Session transcript page kind');
@@ -282,6 +290,28 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa
'Session transcript cursor',
SESSION_TRANSCRIPT_CURSOR_MAX_BYTES,
);
+ const rangeBoundarySequence =
+ result.rangeBoundarySequence === null
+ ? null
+ : requireCount(result.rangeBoundarySequence, 'Session transcript range boundary sequence');
+ const protectedTurnSequence =
+ result.protectedTurnSequence === null
+ ? null
+ : requireCount(result.protectedTurnSequence, 'Session transcript protected Turn sequence');
+ if (
+ (rangeBoundarySequence !== null && source !== 'durable') ||
+ (rangeBoundarySequence !== null &&
+ (throughSequence === null || rangeBoundarySequence > throughSequence))
+ ) {
+ throw invalidProtocolFrame('Invalid Session transcript range boundary');
+ }
+ if (
+ (protectedTurnSequence !== null && source !== 'durable') ||
+ (protectedTurnSequence !== null &&
+ (throughSequence === null || protectedTurnSequence > throughSequence))
+ ) {
+ throw invalidProtocolFrame('Invalid Session transcript protected Turn sequence');
+ }
if (fragments.length === 0 && (rawBytes !== 0 || nextCursor !== null)) {
throw invalidProtocolFrame('Invalid empty Session transcript page');
}
@@ -293,6 +323,8 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa
throughSequence,
rawBytes,
fragments,
+ rangeBoundarySequence,
+ protectedTurnSequence,
nextCursor,
};
}
diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts
index 5c7dba3e0e..d1e20d605d 100644
--- a/packages/runtime-host/src/server/session-transcript-pager.ts
+++ b/packages/runtime-host/src/server/session-transcript-pager.ts
@@ -21,6 +21,8 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { StoredMessage } from '@maka/core/session';
import {
SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
+ SESSION_TRANSCRIPT_RANGE_MAX_BYTES,
+ SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES,
type SessionTranscriptBootstrap,
type SessionTranscriptFragment,
type SessionTranscriptPage,
@@ -38,6 +40,11 @@ import { projectSharedSessionTranscriptMessage } from './shared-session-transcri
type SessionTranscriptProjection = 'owner' | 'shared';
+// Leave one identity for completing the far-edge Turn without exceeding the
+// 256-message active range. The full-range scan below admits an exactly-full
+// Turn and rejects only when the lookahead proves that the Turn continues.
+const DURABLE_RANGE_SEED_MAX_MESSAGES = SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES - 1;
+
interface TranscriptCursorState {
readonly version: 1;
readonly subscriptionId: string;
@@ -47,6 +54,7 @@ interface TranscriptCursorState {
readonly throughSequence: number | null;
readonly position: number;
readonly byteOffset: number | null;
+ readonly rangeBoundarySequence: number | null;
}
export interface SubscriberTranscriptState {
@@ -109,7 +117,7 @@ export async function createSessionTranscriptBootstrap(input: {
direction: 'older',
throughSequence: input.throughSequence,
maxBytes: durableBudget,
- maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
+ maxMessages: DURABLE_RANGE_SEED_MAX_MESSAGES,
} as const;
const durableStorage =
projection === 'shared'
@@ -127,11 +135,27 @@ export async function createSessionTranscriptBootstrap(input: {
cursorSecret,
projection,
};
+ const durableSelection = storageSelection(durableStorage);
+ const rangeEdges = await readRangeEdges({
+ reader: input.reader,
+ state,
+ direction: 'older',
+ throughSequence: input.throughSequence,
+ selected: durableSelection,
+ });
const bootstrap: SessionTranscriptBootstrap = {
throughSequence: input.throughSequence,
durableCoverage: projection === 'shared' ? 'projected' : 'complete',
overlayMessageCount: overlayMessages.length,
- durable: pageFromSelection(state, 'durable', 'older', storageSelection(durableStorage)),
+ durable: pageFromSelection(
+ state,
+ 'durable',
+ 'older',
+ durableSelection,
+ input.throughSequence,
+ rangeEdges.rangeBoundarySequence,
+ rangeEdges.protectedTurnSequence,
+ ),
overlay: pageFromSelection(state, 'overlay', 'older', selectedOverlay),
};
const encodedBytes = Buffer.byteLength(JSON.stringify(bootstrap), 'utf8');
@@ -200,7 +224,7 @@ export async function readSessionTranscriptPage(input: {
position.position,
position.byteOffset,
request.maxBytes,
- SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
+ continuationMessageLimit(position),
);
return pageFromSelection(
state,
@@ -217,83 +241,281 @@ export async function readSessionTranscriptPage(input: {
position: position.position,
...(position.byteOffset === null ? {} : { byteOffset: position.byteOffset }),
maxBytes: request.maxBytes,
- maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
+ maxMessages: continuationMessageLimit(position),
} as const;
const storage =
state.projection === 'shared'
- ? await readSharedDurablePage(input.reader, state.sessionId, durableRequest)
+ ? await readSharedDurablePage(
+ input.reader,
+ state.sessionId,
+ durableRequest,
+ position.rangeBoundarySequence,
+ )
: await input.reader.readDurablePage(state.sessionId, durableRequest);
+ const selected = storageSelection(storage);
+ const rangeEdges = await readRangeEdges({
+ reader: input.reader,
+ state,
+ direction: request.direction,
+ throughSequence: request.throughSequence,
+ selected,
+ });
return pageFromSelection(
state,
'durable',
request.direction,
- storageSelection(storage),
+ selected,
request.throughSequence,
+ rangeEdges.rangeBoundarySequence,
+ rangeEdges.protectedTurnSequence,
+ );
+}
+
+async function readRangeEdges(input: {
+ reader: SessionTranscriptReader;
+ state: SubscriberTranscriptState;
+ direction: SessionTranscriptPageDirection;
+ throughSequence: number | null;
+ selected: SelectedFragments;
+}): Promise<{
+ readonly rangeBoundarySequence: number | null;
+ readonly protectedTurnSequence: number | null;
+}> {
+ if (input.throughSequence === null) {
+ return { rangeBoundarySequence: null, protectedTurnSequence: null };
+ }
+ const selectedSequences = input.selected.fragments.flatMap((fragment) =>
+ fragment.kind === 'durable' ? [fragment.sequence] : [],
);
+ if (selectedSequences.length === 0) {
+ return { rangeBoundarySequence: null, protectedTurnSequence: null };
+ }
+ const boundaryCandidate =
+ input.direction === 'older' ? Math.min(...selectedSequences) : Math.max(...selectedSequences);
+ const scanPosition =
+ input.direction === 'older' ? Math.max(...selectedSequences) : Math.min(...selectedSequences);
+ let targetTurnId: string | undefined;
+ let boundary: number | null = null;
+ let protectedTurnId: string | undefined;
+ let protectedTurnSequence: number | null = null;
+ let latestTurnSequence: number | null = null;
+ let rangeBytes = 0;
+ let rangeMessages = 0;
+ let hiddenBytes = 0;
+ let reachedFarEdge = false;
+ let position: number | null = scanPosition;
+ while (position !== null && !reachedFarEdge) {
+ const scanned = await input.reader.readDurableRecords(input.state.sessionId, {
+ direction: input.direction,
+ throughSequence: input.throughSequence,
+ position,
+ maxStoredBytes: SESSION_TRANSCRIPT_RANGE_MAX_BYTES,
+ maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES,
+ });
+ for (const record of scanned.records) {
+ const message =
+ input.state.projection === 'shared'
+ ? projectSharedSessionTranscriptMessage(record.message, input.state.sessionId)
+ : record.message;
+ if (!message) {
+ hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8');
+ if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) {
+ throw new RangeError('Session transcript projection scan exceeds its capacity limit');
+ }
+ continue;
+ }
+ const turnId = messageTurnId(message);
+ if (boundary !== null && turnId !== targetTurnId) {
+ reachedFarEdge = true;
+ break;
+ }
+ rangeMessages += 1;
+ rangeBytes += Buffer.byteLength(JSON.stringify(message), 'utf8');
+ if (
+ rangeMessages > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES ||
+ rangeBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES
+ ) {
+ throw new RangeError('Session transcript Turn range exceeds its capacity limit');
+ }
+ if (input.direction === 'newer' && turnId !== undefined) {
+ latestTurnSequence = record.sequence;
+ }
+ if (input.direction === 'older' && protectedTurnSequence === null) {
+ if (protectedTurnId === undefined && turnId !== undefined) protectedTurnId = turnId;
+ if (turnId === protectedTurnId) protectedTurnSequence = record.sequence;
+ }
+ if (targetTurnId === undefined && record.sequence === boundaryCandidate) {
+ if (turnId === undefined) {
+ boundary = record.sequence;
+ reachedFarEdge = true;
+ break;
+ }
+ targetTurnId = turnId;
+ }
+ if (turnId === targetTurnId) {
+ boundary = record.sequence;
+ }
+ }
+ if (reachedFarEdge || scanned.nextPosition === null) {
+ position = scanned.nextPosition;
+ break;
+ }
+ if (scanned.nextPosition === position) {
+ throw new Error('Session transcript projection scan did not advance');
+ }
+ position = scanned.nextPosition;
+ }
+ if (boundary === null) {
+ throw new Error('Session transcript range did not reach its authoritative Turn');
+ }
+ if (!reachedFarEdge && position !== null) {
+ const lookahead = await readNextProjectedDurableRecord({
+ reader: input.reader,
+ state: input.state,
+ direction: input.direction,
+ throughSequence: input.throughSequence,
+ position,
+ });
+ if (lookahead && messageTurnId(lookahead.message) === targetTurnId) {
+ throw new RangeError('Session transcript Turn range exceeds its capacity limit');
+ }
+ }
+ return {
+ rangeBoundarySequence: boundary,
+ protectedTurnSequence:
+ input.direction === 'older'
+ ? (protectedTurnSequence ?? boundary)
+ : (latestTurnSequence ?? boundary),
+ };
+}
+
+async function readNextProjectedDurableRecord(input: {
+ reader: SessionTranscriptReader;
+ state: SubscriberTranscriptState;
+ direction: SessionTranscriptPageDirection;
+ throughSequence: number;
+ position: number;
+}): Promise<{ readonly sequence: number; readonly message: StoredMessage } | null> {
+ let position: number | null = input.position;
+ let hiddenBytes = 0;
+ while (position !== null) {
+ const scanned = await input.reader.readDurableRecords(input.state.sessionId, {
+ direction: input.direction,
+ throughSequence: input.throughSequence,
+ position,
+ maxStoredBytes: SESSION_TRANSCRIPT_RANGE_MAX_BYTES,
+ maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES,
+ });
+ for (const record of scanned.records) {
+ const projected =
+ input.state.projection === 'shared'
+ ? projectSharedSessionTranscriptMessage(record.message, input.state.sessionId)
+ : record.message;
+ if (projected) return { sequence: record.sequence, message: projected };
+ hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8');
+ if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) {
+ throw new RangeError('Session transcript projection scan exceeds its capacity limit');
+ }
+ }
+ if (scanned.nextPosition === position) {
+ throw new Error('Session transcript projection scan did not advance');
+ }
+ position = scanned.nextPosition;
+ }
+ return null;
+}
+
+function messageTurnId(message: StoredMessage): string | undefined {
+ const turnId = 'turnId' in message ? message.turnId : undefined;
+ return typeof turnId === 'string' ? turnId : undefined;
}
async function readSharedDurablePage(
reader: SessionTranscriptReader,
sessionId: string,
request: Parameters<SessionTranscriptReader['readDurablePage']>[1],
+ rangeBoundarySequence: number | null = null,
): ReturnType<SessionTranscriptReader['readDurablePage']> {
const position =
request.position ??
(request.direction === 'older' ? (request.throughSequence ?? undefined) : 0);
- const scanned = await reader.readDurableRecords(sessionId, {
- direction: request.direction,
- ...(request.throughSequence === undefined ? {} : { throughSequence: request.throughSequence }),
- ...(position === undefined ? {} : { position }),
- maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
- maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
- });
const fragments: Awaited<
ReturnType<SessionTranscriptReader['readDurablePage']>
>['fragments'][number][] = [];
let rawBytes = 0;
+ let hiddenBytes = 0;
let next: { position: number; byteOffset: number | null } | null = null;
- let recordIndex = 0;
- for (; recordIndex < scanned.records.length; recordIndex += 1) {
- const record = scanned.records[recordIndex]!;
- const projected = projectSharedSessionTranscriptMessage(record.message, sessionId);
- if (!projected) continue;
- const bytes = Buffer.from(JSON.stringify(projected), 'utf8');
- const continuationOffset =
- record.sequence === position && request.byteOffset !== undefined ? request.byteOffset : null;
- const selected = selectBuffer(
- bytes,
- request.direction,
- continuationOffset,
- request.maxBytes - rawBytes,
- );
- if (!selected) break;
- fragments.push({
- sequence: record.sequence,
- byteOffset: selected.byteOffset,
- totalBytes: bytes.byteLength,
- payloadDigest: null,
- data: selected.data,
+ let scanPosition = position;
+ let throughSequence = request.throughSequence ?? null;
+ while (scanPosition !== undefined && next === null) {
+ const scanned = await reader.readDurableRecords(sessionId, {
+ direction: request.direction,
+ ...(request.throughSequence === undefined
+ ? {}
+ : { throughSequence: request.throughSequence }),
+ position: scanPosition,
+ maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+ maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
});
- rawBytes += selected.data.byteLength;
- if (!selected.complete) {
- next = { position: record.sequence, byteOffset: selected.nextOffset };
- break;
+ throughSequence = scanned.throughSequence;
+ let recordIndex = 0;
+ for (; recordIndex < scanned.records.length; recordIndex += 1) {
+ const record = scanned.records[recordIndex]!;
+ const projected = projectSharedSessionTranscriptMessage(record.message, sessionId);
+ if (!projected) {
+ hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8');
+ if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) {
+ throw new RangeError('Session transcript projection scan exceeds its capacity limit');
+ }
+ continue;
+ }
+ const bytes = Buffer.from(JSON.stringify(projected), 'utf8');
+ const continuationOffset =
+ record.sequence === position && request.byteOffset !== undefined
+ ? request.byteOffset
+ : null;
+ const selected = selectBuffer(
+ bytes,
+ request.direction,
+ continuationOffset,
+ request.maxBytes - rawBytes,
+ );
+ if (!selected) {
+ next = { position: record.sequence, byteOffset: null };
+ break;
+ }
+ fragments.push({
+ sequence: record.sequence,
+ byteOffset: selected.byteOffset,
+ totalBytes: bytes.byteLength,
+ payloadDigest: null,
+ data: selected.data,
+ });
+ rawBytes += selected.data.byteLength;
+ if (!selected.complete) {
+ next = { position: record.sequence, byteOffset: selected.nextOffset };
+ break;
+ }
+ if (record.sequence === rangeBoundarySequence) {
+ const following = scanned.records[recordIndex + 1]?.sequence ?? scanned.nextPosition;
+ next = following === null ? null : { position: following, byteOffset: null };
+ break;
+ }
+ if (fragments.length === request.maxMessages || rawBytes === request.maxBytes) {
+ const following = scanned.records[recordIndex + 1]?.sequence ?? scanned.nextPosition;
+ next = following === null ? null : { position: following, byteOffset: null };
+ break;
+ }
}
- if (fragments.length === request.maxMessages || rawBytes === request.maxBytes) {
- recordIndex += 1;
- break;
+ if (next !== null) break;
+ if (scanned.nextPosition === null) break;
+ if (scanned.nextPosition === scanPosition) {
+ throw new Error('Session transcript projection scan did not advance');
}
- }
- if (!next) {
- next =
- recordIndex < scanned.records.length
- ? { position: scanned.records[recordIndex]!.sequence, byteOffset: null }
- : scanned.nextPosition === null
- ? null
- : { position: scanned.nextPosition, byteOffset: null };
+ scanPosition = scanned.nextPosition;
}
return {
- throughSequence: scanned.throughSequence,
+ throughSequence,
fragments,
rawBytes,
next,
@@ -327,7 +549,11 @@ export class TranscriptPageRequestError extends Error {
function resolvePosition(
state: SubscriberTranscriptState,
request: SessionTranscriptPageInput,
-): { position: number; byteOffset: number | null } | null {
+): {
+ position: number;
+ byteOffset: number | null;
+ rangeBoundarySequence: number | null;
+} | null {
if (request.cursor !== null) {
const cursor = decodeCursor(request.cursor, state.cursorSecret);
if (
@@ -339,7 +565,11 @@ function resolvePosition(
) {
throw new TranscriptPageRequestError('Transcript cursor does not match request');
}
- return { position: cursor.position, byteOffset: cursor.byteOffset };
+ return {
+ position: cursor.position,
+ byteOffset: cursor.byteOffset,
+ rangeBoundarySequence: cursor.rangeBoundarySequence,
+ };
}
if (request.source === 'overlay') {
const overlayMessages = state.overlayMessages;
@@ -349,14 +579,28 @@ function resolvePosition(
request.direction === 'older' ? (anchor ?? overlayMessages.length) - 1 : (anchor ?? -1) + 1;
return position < 0 || position >= overlayMessages.length
? null
- : { position, byteOffset: null };
+ : { position, byteOffset: null, rangeBoundarySequence: null };
}
if (request.throughSequence === null) return null;
const position =
request.direction === 'older'
? (request.anchorSequence ?? request.throughSequence + 1) - 1
: (request.anchorSequence ?? -1) + 1;
- return position < 0 || position > request.throughSequence ? null : { position, byteOffset: null };
+ return position < 0 || position > request.throughSequence
+ ? null
+ : { position, byteOffset: null, rangeBoundarySequence: null };
+}
+
+function continuationMessageLimit(position: {
+ position: number;
+ rangeBoundarySequence: number | null;
+}): number {
+ return position.rangeBoundarySequence === null
+ ? DURABLE_RANGE_SEED_MAX_MESSAGES
+ : Math.min(
+ DURABLE_RANGE_SEED_MAX_MESSAGES,
+ Math.abs(position.position - position.rangeBoundarySequence) + 1,
+ );
}
function storageSelection(
@@ -465,6 +709,8 @@ function pageFromSelection(
direction: SessionTranscriptPageDirection,
selected: SelectedFragments,
throughSequence: number | null = state.openedThroughSequence,
+ rangeBoundarySequence: number | null = null,
+ protectedTurnSequence: number | null = null,
): SessionTranscriptPage {
return {
kind: 'page',
@@ -474,6 +720,8 @@ function pageFromSelection(
throughSequence,
rawBytes: selected.rawBytes,
fragments: selected.fragments,
+ rangeBoundarySequence,
+ protectedTurnSequence,
nextCursor: selected.next
? encodeCursor(
{
@@ -483,6 +731,7 @@ function pageFromSelection(
source,
direction,
throughSequence,
+ rangeBoundarySequence,
...selected.next,
},
state.cursorSecret,
@@ -503,6 +752,8 @@ function emptyPage(
throughSequence: request.throughSequence,
rawBytes: 0,
fragments: [],
+ rangeBoundarySequence: null,
+ protectedTurnSequence: null,
nextCursor: null,
};
}
@@ -546,6 +797,7 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState {
'throughSequence',
'position',
'byteOffset',
+ 'rangeBoundarySequence',
];
if (
Object.keys(cursor).length !== keys.length ||
@@ -561,7 +813,8 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState {
(cursor.direction !== 'older' && cursor.direction !== 'newer') ||
(cursor.throughSequence !== null && !isCount(cursor.throughSequence)) ||
!isCount(cursor.position) ||
- (cursor.byteOffset !== null && !isCount(cursor.byteOffset))
+ (cursor.byteOffset !== null && !isCount(cursor.byteOffset)) ||
+ (cursor.rangeBoundarySequence !== null && !isCount(cursor.rangeBoundarySequence))
) {
throw new TranscriptPageRequestError('Invalid transcript cursor values');
}
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<typeof createRoot> | 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('<main id="root"></main>');
- const root = document.querySelector<HTMLElement>('#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<number, FrameRequestCallback>();
- 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<HTMLElement | null>(root);
- const range = useTurnVirtualizer({ sessionId: 'tall-session', turnIds, scrollRef });
- return (
- <div data-range={`${range.start}:${range.end}`}>
- {turnIds.slice(range.start, range.end).map((turnId) => (
- <div key={turnId} data-virtual-turn-id={turnId} />
- ))}
- </div>
- );
- }
-
- mountedRoot = createRoot(root);
- await act(() => mountedRoot?.render(<Harness />));
- 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<HTMLElement>('[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<ComponentProps<typeof ChatLayout>, '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> | void;
+ onLoadEarlierHistory?(anchorTurnId?: string): Promise<void> | 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 && (
- <div
- aria-hidden="true"
- className="maka-turn-virtual-spacer"
- style={{ height: beforeHeight, flex: '0 0 auto', transition: 'none' }}
- />
- )}
- {mountedTurns.map((turn) => {
+ {turns.map((turn) => {
return (
<div
key={turn.turnId}
- className="maka-turn-virtual-item"
- data-virtual-turn-id={turn.turnId}
+ className="maka-transcript-turn"
+ data-transcript-turn-id={turn.turnId}
>
{turn.turnId === tailTurnId
? inlineTransientMessages.map((message) => (
@@ -790,13 +765,6 @@ export function ChatView(props: {
</div>
);
})}
- {afterHeight > 0 && (
- <div
- aria-hidden="true"
- className="maka-turn-virtual-spacer"
- style={{ height: afterHeight, flex: '0 0 auto', transition: 'none' }}
- />
- )}
{transientMessages.filter(
(message) => !inlineTransientMessageIds.has(message.id),
).map((message) => (
diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx
index f1afa851b8..473abd2274 100644
--- a/packages/ui/src/prompt-anchor-rail.tsx
+++ b/packages/ui/src/prompt-anchor-rail.tsx
@@ -50,7 +50,7 @@ const MAX_PROMPT_RAIL_TICKS = 64;
const JUMP_SETTLE_QUIET_FRAMES = 3;
/**
* Frames a hold may run before it gives up regardless. Only a backstop against
- * a destination that never mounts; this is ~4s at 60Hz.
+ * a destination that never becomes available; this is ~4s at 60Hz.
*/
const JUMP_HOLD_FRAME_BUDGET = 240;
/** How close to the scrollport's top edge counts as landed. */
@@ -77,13 +77,12 @@ const browserFrameScheduler: PromptRailFrameScheduler = {
/**
* Hold a jump's destination while the transcript grows under it.
*
- * What moves the transcript out from under a jump is the virtual window: the
- * destination mounts, its measured height replaces the estimate, and everything
- * below shifts. Releasing the tail (`onNavigateStart`) does not answer that —
+ * 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, which is what a scroll cancelled
- * part-way leaves behind. A frame where nothing moved and the target is where
- * the click asked costs one `getBoundingClientRect` and nothing else.
+ * 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
@@ -146,7 +145,7 @@ export function holdJumpDestination(input: {
const moved = root.scrollTop !== lastTop;
lastHeight = root.scrollHeight;
lastTop = root.scrollTop;
- // Growth is the virtual window replacing estimates; re-aim through it.
+ // 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.
@@ -234,7 +233,7 @@ export function mergePromptAnchorRailTurns(
export interface PromptAnchorRailProps {
turns: readonly PromptAnchorRailTurn[];
scrollRef: RefObject<HTMLElement | null>;
- /** When the bounded virtual window has not placed the turn in the DOM. */
+ /** When the indexed Turn is outside the Host's active transcript range. */
onNavigateFallback?: (turn: PromptAnchorRailTurn) => void;
/**
* Stop following the tail, before a jump scrolls.
@@ -455,7 +454,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
// 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 virtual-window geometry changes and reports back
+ // 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;
@@ -492,9 +491,9 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
// 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
// does not survive this surface: traced against a 30-prompt session, the
- // smooth scroll was cancelled by the mount's own scroll compensation and
- // stalled two pixels from where it started. Landing reliably beats
- // animating unreliably.
+ // smooth scroll was cancelled by concurrent content growth and stalled
+ // two pixels from where it started. Landing reliably beats animating
+ // unreliably.
(el as HTMLElement).scrollIntoView({ behavior: 'auto', block: 'start' });
} else if (!el) {
onNavigateFallback?.(turn);
diff --git a/packages/ui/src/turn-height-index.ts b/packages/ui/src/turn-height-index.ts
deleted file mode 100644
index 6f6df8de2e..0000000000
--- a/packages/ui/src/turn-height-index.ts
+++ /dev/null
@@ -1,87 +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.
- */
-
-interface SessionTurnHeights {
- layoutKey: string;
- heights: Map<string, number>;
-}
-
-export interface TurnHeightIndex {
- lookup(sessionId: string, layoutKey: string): ReadonlyMap<string, number> | undefined;
- record(sessionId: string, layoutKey: string, turnId: string, height: number): boolean;
-}
-
-export function turnLayoutGap(root: HTMLElement, fallback: number): number {
- const column = root.querySelector<HTMLElement>('.maka-chat-message-list');
- const rows = column?.firstElementChild;
- const styles = rows && root.ownerDocument.defaultView?.getComputedStyle(rows);
- const parsed = Number.parseFloat(styles?.rowGap ?? styles?.gap ?? '');
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
-}
-
-export function turnLayoutKey(root: HTMLElement, gap: number): string {
- const column = root.querySelector<HTMLElement>('.maka-chat-message-list');
- return `${Math.round((column ?? root).clientWidth)}:${gap}:${root.dataset.density ?? ''}`;
-}
-
-export function createTurnHeightIndex(sessionCapacity = 12, turnCapacity = 1_024): TurnHeightIndex {
- const sessions = new Map<string, SessionTurnHeights>();
-
- const entryFor = (sessionId: string, layoutKey: string): SessionTurnHeights => {
- const current = sessions.get(sessionId);
- if (current?.layoutKey === layoutKey) {
- sessions.delete(sessionId);
- sessions.set(sessionId, current);
- return current;
- }
- const next = { layoutKey, heights: new Map<string, number>() };
- sessions.delete(sessionId);
- sessions.set(sessionId, next);
- while (sessions.size > sessionCapacity) {
- const oldest = sessions.keys().next().value;
- if (oldest === undefined) break;
- sessions.delete(oldest);
- }
- return next;
- };
-
- return {
- lookup(sessionId, layoutKey) {
- const entry = sessions.get(sessionId);
- if (entry?.layoutKey !== layoutKey) return undefined;
- sessions.delete(sessionId);
- sessions.set(sessionId, entry);
- return entry.heights;
- },
- record(sessionId, layoutKey, turnId, height) {
- if (!Number.isFinite(height) || height <= 0) return false;
- const entry = entryFor(sessionId, layoutKey);
- const previous = entry.heights.get(turnId);
- if (previous !== undefined && Math.abs(previous - height) < 0.5) return false;
- entry.heights.delete(turnId);
- entry.heights.set(turnId, height);
- while (entry.heights.size > turnCapacity) {
- const oldest = entry.heights.keys().next().value;
- if (oldest === undefined) break;
- entry.heights.delete(oldest);
- }
- return true;
- },
- };
-}
diff --git a/packages/ui/src/turn-virtualizer.ts b/packages/ui/src/turn-virtualizer.ts
deleted file mode 100644
index 88489657e5..0000000000
--- a/packages/ui/src/turn-virtualizer.ts
+++ /dev/null
@@ -1,353 +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.
- */
-
-export interface TurnVirtualLayout {
- readonly turnIds: readonly string[];
- readonly offsets: readonly number[];
- readonly totalHeight: number;
- readonly gap: number;
-}
-
-export interface TurnVirtualWindow {
- readonly start: number;
- readonly end: number;
- readonly beforeHeight: number;
- readonly afterHeight: number;
-}
-
-export interface TurnVirtualWindowOptions {
- readonly overscanPx?: number;
- readonly preferredTurns?: number;
- readonly maxTurns?: number;
- readonly ensureIndex?: number;
- readonly retainRange?: { readonly start: number; readonly end: number };
-}
-
-export const DEFAULT_TURN_ESTIMATED_HEIGHT = 280;
-export const DEFAULT_TURN_GAP = 16;
-export const DEFAULT_TURN_OVERSCAN_PX = 1_000;
-export const DEFAULT_TURN_WINDOW_SIZE = 60;
-export const MAX_TURN_WINDOW_SIZE = 100;
-export const INITIAL_TURN_WINDOW_SIZE = 2;
-export const MIN_TURN_WINDOW_SIZE = 2;
-const TURN_WINDOW_SHIFT_SIZE = 8;
-
-export function estimatedTurnHeight(heights: ReadonlyMap<string, number> | undefined): number {
- if (!heights || heights.size === 0) return DEFAULT_TURN_ESTIMATED_HEIGHT;
- let total = 0;
- for (const height of heights.values()) total += height;
- return (DEFAULT_TURN_ESTIMATED_HEIGHT + total) / (heights.size + 1);
-}
-
-export function turnVirtualizationRequired(
- layout: TurnVirtualLayout,
- viewportHeight: number | undefined,
- renderedHeight = 0,
-): boolean {
- if (layout.turnIds.length > MAX_TURN_WINDOW_SIZE) return true;
- if (viewportHeight === undefined || viewportHeight <= 0) return false;
- return Math.max(layout.totalHeight, renderedHeight)
- > viewportHeight + (2 * DEFAULT_TURN_OVERSCAN_PX);
-}
-
-export function buildTurnVirtualLayout(
- turnIds: readonly string[],
- heights: ReadonlyMap<string, number> | undefined,
- options: { estimatedHeight?: number; gap?: number } = {},
-): TurnVirtualLayout {
- const estimatedHeight = positive(options.estimatedHeight, DEFAULT_TURN_ESTIMATED_HEIGHT);
- const gap = nonNegative(options.gap, DEFAULT_TURN_GAP);
- const offsets = new Array<number>(turnIds.length + 1);
- offsets[0] = 0;
- for (let index = 0; index < turnIds.length; index += 1) {
- const measured = heights?.get(turnIds[index]!);
- const height = positive(measured, estimatedHeight);
- offsets[index + 1] = offsets[index]! + height + (index + 1 < turnIds.length ? gap : 0);
- }
- return { turnIds, offsets, totalHeight: offsets.at(-1) ?? 0, gap };
-}
-
-export function initialTurnVirtualWindow(
- layout: TurnVirtualLayout,
- count = INITIAL_TURN_WINDOW_SIZE,
- ensureIndex?: number,
-): TurnVirtualWindow {
- if (layout.turnIds.length === 0) return windowFor(layout, 0, 0);
- const size = Math.min(layout.turnIds.length, Math.max(1, Math.floor(count)));
- if (ensureIndex === undefined) {
- return windowFor(layout, layout.turnIds.length - size, layout.turnIds.length);
- }
- const start = clamp(Math.floor(ensureIndex) - Math.floor(size / 2), 0, layout.turnIds.length - size);
- return windowFor(layout, start, start + size);
-}
-
-export function turnVirtualWindowForRange(
- layout: TurnVirtualLayout,
- start: number,
- end: number,
-): TurnVirtualWindow {
- return windowFor(layout, start, end);
-}
-
-export function turnVirtualWindowForViewport(
- layout: TurnVirtualLayout,
- viewport: { scrollTop: number; clientHeight: number },
- options: TurnVirtualWindowOptions = {},
-): TurnVirtualWindow {
- const length = layout.turnIds.length;
- if (length === 0) return windowFor(layout, 0, 0);
- const scrollTop = clamp(viewport.scrollTop, 0, layout.totalHeight);
- const clientHeight = Math.max(0, viewport.clientHeight);
- const overscan = nonNegative(options.overscanPx, DEFAULT_TURN_OVERSCAN_PX);
- const visibleStart = indexAtOffset(layout, scrollTop);
- const visibleEnd = Math.max(visibleStart + 1, indexAfterOffset(layout, scrollTop + clientHeight));
- let start = indexAtOffset(layout, Math.max(0, scrollTop - overscan));
- let end = indexAfterOffset(layout, Math.min(layout.totalHeight, scrollTop + clientHeight + overscan));
-
- const preferred = Math.max(1, Math.floor(options.preferredTurns ?? DEFAULT_TURN_WINDOW_SIZE));
- const hardMax = hardMaxTurns(options, preferred);
- const retained = retainedRange(options, length);
- ({ start, end } = includeRange(start, end, retained));
- ({ start, end } = expandRange(start, end, preferred, length));
-
- if (end - start > hardMax) {
- const requiredStart = Math.min(visibleStart, retained?.start ?? visibleStart);
- const requiredEnd = Math.max(visibleEnd, retained?.end ?? visibleEnd);
- const requiredSize = requiredEnd - requiredStart;
- const size = Math.min(length, hardMax);
- const center = requiredSize <= hardMax
- ? (requiredStart + requiredEnd) / 2
- : (visibleStart + visibleEnd) / 2;
- start = clamp(Math.round(center - size / 2), 0, length - size);
- end = start + size;
- }
-
- return windowFor(layout, start, end);
-}
-
-export function stableTurnVirtualWindowForViewport(
- layout: TurnVirtualLayout,
- current: TurnVirtualWindow,
- viewport: { scrollTop: number; clientHeight: number },
- options: TurnVirtualWindowOptions = {},
-): TurnVirtualWindow {
- const minimumSize = Math.min(layout.turnIds.length, MIN_TURN_WINDOW_SIZE);
- if (current.end - current.start < minimumSize) {
- return turnVirtualWindowForViewport(layout, viewport, options);
- }
- if (
- current.start === 0
- && current.end === layout.turnIds.length
- && current.end - current.start > minimumSize
- ) {
- const bounded = turnVirtualWindowForViewport(layout, viewport, options);
- if (bounded.end - bounded.start < current.end - current.start) return bounded;
- }
- const overscan = nonNegative(options.overscanPx, DEFAULT_TURN_OVERSCAN_PX);
- const scrollTop = clamp(viewport.scrollTop, 0, layout.totalHeight);
- const requiredStart = Math.max(0, scrollTop - overscan);
- const requiredEnd = Math.min(
- layout.totalHeight,
- scrollTop + Math.max(0, viewport.clientHeight) + overscan,
- );
- const retained = retainedRange(options, layout.turnIds.length);
- const retainsTarget = retained === undefined
- || (retained.start >= current.start && retained.end <= current.end);
- if (
- current.end > current.start
- && retainsTarget
- && requiredStart >= layout.offsets[current.start]!
- && requiredEnd <= layout.offsets[current.end]!
- ) {
- return turnVirtualWindowForRange(layout, current.start, current.end);
- }
- if (current.end > current.start && retainsTarget) {
- const size = current.end - current.start;
- const shiftSize = Math.min(TURN_WINDOW_SHIFT_SIZE, size);
- if (
- requiredStart >= layout.offsets[current.start]!
- && requiredEnd > layout.offsets[current.end]!
- ) {
- let end = current.end;
- while (end < layout.turnIds.length && layout.offsets[end]! < requiredEnd) {
- end = Math.min(layout.turnIds.length, end + shiftSize);
- }
- const shifted = { start: Math.max(0, end - size), end };
- const inclusive = includeRange(shifted.start, shifted.end, retained);
- if (
- inclusive.end - inclusive.start <= hardMaxTurns(options)
- && requiredStart >= layout.offsets[inclusive.start]!
- && requiredEnd <= layout.offsets[inclusive.end]!
- ) {
- return windowFor(layout, inclusive.start, inclusive.end);
- }
- }
- if (
- requiredStart < layout.offsets[current.start]!
- && requiredEnd <= layout.offsets[current.end]!
- ) {
- let start = current.start;
- while (start > 0 && layout.offsets[start]! > requiredStart) {
- start = Math.max(0, start - shiftSize);
- }
- const shifted = { start, end: Math.min(layout.turnIds.length, start + size) };
- const inclusive = includeRange(shifted.start, shifted.end, retained);
- if (
- inclusive.end - inclusive.start <= hardMaxTurns(options)
- && requiredStart >= layout.offsets[inclusive.start]!
- && requiredEnd <= layout.offsets[inclusive.end]!
- ) {
- return windowFor(layout, inclusive.start, inclusive.end);
- }
- }
- }
- return turnVirtualWindowForViewport(layout, viewport, options);
-}
-
-export function reconcileTurnVirtualWindow(
- previousIds: readonly string[],
- nextLayout: TurnVirtualLayout,
- previous: TurnVirtualWindow,
- ensureIndex?: number,
- retainRange?: { readonly start: number; readonly end: number },
-): TurnVirtualWindow {
- const nextIds = nextLayout.turnIds;
- if (nextIds.length === 0) return windowFor(nextLayout, 0, 0);
- if (previousIds.length === 0 || previous.end <= previous.start) {
- return initialTurnVirtualWindow(nextLayout, MIN_TURN_WINDOW_SIZE, ensureIndex);
- }
- const firstId = previousIds[previous.start];
- const lastId = previousIds[previous.end - 1];
- let start = firstId === undefined ? -1 : nextIds.indexOf(firstId);
- const last = lastId === undefined ? -1 : nextIds.indexOf(lastId);
- if (start < 0 || last < start) {
- return initialTurnVirtualWindow(nextLayout, MIN_TURN_WINDOW_SIZE, ensureIndex);
- }
- let end = last + 1;
- const retained = retainedRange({ ensureIndex, retainRange }, nextIds.length);
- ({ start, end } = includeRange(start, end, retained));
- ({ start, end } = expandRange(
- start,
- end,
- Math.min(nextIds.length, MIN_TURN_WINDOW_SIZE),
- nextIds.length,
- ));
- if (end - start > MAX_TURN_WINDOW_SIZE) {
- const focus = ensureIndex ?? (retained === undefined
- ? Math.floor((start + end) / 2)
- : Math.floor((retained.start + retained.end) / 2));
- start = clamp(focus - Math.floor(MAX_TURN_WINDOW_SIZE / 2), 0, nextIds.length - MAX_TURN_WINDOW_SIZE);
- end = Math.min(nextIds.length, start + MAX_TURN_WINDOW_SIZE);
- }
- return windowFor(nextLayout, start, end);
-}
-
-function windowFor(layout: TurnVirtualLayout, start: number, end: number): TurnVirtualWindow {
- const boundedStart = clamp(start, 0, layout.turnIds.length);
- const boundedEnd = clamp(end, boundedStart, layout.turnIds.length);
- return {
- start: boundedStart,
- end: boundedEnd,
- beforeHeight: boundedStart === 0 ? 0 : Math.max(0, layout.offsets[boundedStart]! - layout.gap),
- afterHeight: boundedEnd === layout.turnIds.length
- ? 0
- : Math.max(0, layout.totalHeight - layout.offsets[boundedEnd]!),
- };
-}
-
-function indexAtOffset(layout: TurnVirtualLayout, offset: number): number {
- let low = 0;
- let high = layout.turnIds.length;
- while (low < high) {
- const middle = Math.floor((low + high) / 2);
- if (layout.offsets[middle + 1]! <= offset) low = middle + 1;
- else high = middle;
- }
- return Math.min(low, Math.max(0, layout.turnIds.length - 1));
-}
-
-function indexAfterOffset(layout: TurnVirtualLayout, offset: number): number {
- let low = 0;
- let high = layout.turnIds.length;
- while (low < high) {
- const middle = Math.floor((low + high) / 2);
- if (layout.offsets[middle]! < offset) low = middle + 1;
- else high = middle;
- }
- return clamp(low, 1, layout.turnIds.length);
-}
-
-function retainedRange(
- options: TurnVirtualWindowOptions,
- length: number,
-): { start: number; end: number } | undefined {
- let start = length;
- let end = 0;
- if (options.ensureIndex !== undefined && options.ensureIndex >= 0 && options.ensureIndex < length) {
- start = options.ensureIndex;
- end = options.ensureIndex + 1;
- }
- if (options.retainRange !== undefined) {
- const rangeStart = clamp(Math.floor(options.retainRange.start), 0, length);
- const rangeEnd = clamp(Math.ceil(options.retainRange.end), rangeStart, length);
- if (rangeEnd > rangeStart) {
- start = Math.min(start, rangeStart);
- end = Math.max(end, rangeEnd);
- }
- }
- return end > start ? { start, end } : undefined;
-}
-
-function hardMaxTurns(
- options: TurnVirtualWindowOptions,
- preferred = Math.max(1, Math.floor(options.preferredTurns ?? DEFAULT_TURN_WINDOW_SIZE)),
-): number {
- return Math.max(preferred, Math.floor(options.maxTurns ?? MAX_TURN_WINDOW_SIZE));
-}
-
-function includeRange(
- start: number,
- end: number,
- retained: { readonly start: number; readonly end: number } | undefined,
-) {
- if (retained === undefined) return { start, end };
- return { start: Math.min(start, retained.start), end: Math.max(end, retained.end) };
-}
-
-function expandRange(start: number, end: number, size: number, length: number) {
- if (end - start >= size || end - start >= length) return { start, end };
- const missing = Math.min(size, length) - (end - start);
- const before = Math.min(start, Math.floor(missing / 2));
- start -= before;
- end = Math.min(length, end + missing - before);
- start = Math.max(0, end - Math.min(size, length));
- return { start, end };
-}
-
-function positive(value: number | undefined, fallback: number): number {
- return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback;
-}
-
-function nonNegative(value: number | undefined, fallback: number): number {
- return value !== undefined && Number.isFinite(value) && value >= 0 ? value : fallback;
-}
-
-function clamp(value: number, minimum: number, maximum: number): number {
- return Math.min(maximum, Math.max(minimum, value));
-}
diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts
index 9def1060a5..5e90444e2a 100644
--- a/packages/ui/src/use-chat-scroll.ts
+++ b/packages/ui/src/use-chat-scroll.ts
@@ -43,7 +43,7 @@ export function useChatScroll(input: {
target?: { turnId: string; nonce: number };
behavior: ScrollBehavior;
hasOlderHistory?: boolean;
- onLoadEarlierHistory?(): Promise<void> | void;
+ onLoadEarlierHistory?(anchorTurnId?: string): Promise<void> | void;
}) {
const [highlightedTurnId, setHighlightedTurnId] = useState<string | null>(null);
const authority = useTranscriptScrollAuthority();
@@ -72,13 +72,17 @@ export function useChatScroll(input: {
// request while one is in flight, and asking for history the reader
// already has is idempotent anyway.
const requestEarlier = (): void => {
+ const rootTop = root.getBoundingClientRect().top;
+ const anchor = [...root.querySelectorAll<HTMLElement>('[data-turn-id]')].find(
+ (turn) => turn.getBoundingClientRect().bottom > rootTop,
+ );
// 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 (root.scrollTop < 1) root.scrollTop = 1;
- void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined);
+ void Promise.resolve(loadEarlierRef.current?.(anchor?.dataset.turnId)).catch(() => undefined);
};
/** Close enough to the start that the reader is about to reach it. */
const nearStart = (): boolean =>
@@ -99,6 +103,13 @@ export function useChatScroll(input: {
// is below.
const onWheel = (event: WheelEvent): void => {
if (event.deltaY >= 0 || !nearStart()) return;
+ for (const target of event.composedPath()) {
+ if (target === root) break;
+ if (!(target instanceof HTMLElement)) continue;
+ const overflowY = getComputedStyle(target).overflowY;
+ if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue;
+ if (target.scrollHeight > target.clientHeight && target.scrollTop > 0) return;
+ }
authority.releasePin();
requestEarlier();
};
diff --git a/packages/ui/src/use-turn-virtualizer.ts b/packages/ui/src/use-turn-virtualizer.ts
deleted file mode 100644
index 2f43abadf1..0000000000
--- a/packages/ui/src/use-turn-virtualizer.ts
+++ /dev/null
@@ -1,358 +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 {
- useCallback,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
- type RefObject,
-} from 'react';
-import { createTurnHeightIndex, turnLayoutGap, turnLayoutKey } from './turn-height-index.js';
-import {
- buildTurnVirtualLayout,
- DEFAULT_TURN_GAP,
- DEFAULT_TURN_WINDOW_SIZE,
- estimatedTurnHeight,
- initialTurnVirtualWindow,
- MIN_TURN_WINDOW_SIZE,
- reconcileTurnVirtualWindow,
- stableTurnVirtualWindowForViewport,
- turnVirtualWindowForRange,
- turnVirtualWindowForViewport,
- turnVirtualizationRequired,
- type TurnVirtualLayout,
- type TurnVirtualWindow,
-} from './turn-virtualizer.js';
-
-const turnHeightIndex = createTurnHeightIndex();
-
-interface VirtualState {
- readonly sessionId: string | undefined;
- readonly turnIds: readonly string[];
- readonly window: TurnVirtualWindow;
-}
-
-export function useTurnVirtualizer(input: {
- sessionId: string | undefined;
- turnIds: readonly string[];
- targetTurnId?: string;
- targetKey?: string | number;
- scrollRef: RefObject<HTMLElement | null>;
-}) {
- const [geometryRevision, setGeometryRevision] = useState(0);
- const root = input.scrollRef.current;
- const gap = root ? turnLayoutGap(root, DEFAULT_TURN_GAP) : DEFAULT_TURN_GAP;
- const layoutKey = root ? turnLayoutKey(root, gap) : '';
- const heights = input.sessionId && layoutKey
- ? turnHeightIndex.lookup(input.sessionId, layoutKey)
- : undefined;
- const layout = useMemo(
- () => buildTurnVirtualLayout(input.turnIds, heights, {
- estimatedHeight: estimatedTurnHeight(heights),
- gap,
- }),
- [input.turnIds, heights, gap, geometryRevision],
- );
- const targetKey = input.targetTurnId === undefined
- ? undefined
- : `${input.targetTurnId}:${input.targetKey ?? ''}`;
- const handledTargetKey = useRef<string | undefined>(undefined);
- const candidateEnsureIndex = input.targetTurnId !== undefined
- && targetKey !== handledTargetKey.current
- ? input.turnIds.indexOf(input.targetTurnId)
- : -1;
- const ensureIndex = candidateEnsureIndex < 0 ? undefined : candidateEnsureIndex;
- const retainRange = root ? interactiveTurnRange(root, input.turnIds) : undefined;
- const virtualizationRequired = turnVirtualizationRequired(
- layout,
- root?.clientHeight,
- root?.scrollHeight,
- );
- const [state, setState] = useState<VirtualState>(() => ({
- sessionId: input.sessionId,
- turnIds: input.turnIds,
- window: initialTurnVirtualWindow(layout, undefined, ensureIndex),
- }));
- let current = state;
- if (!virtualizationRequired) {
- const fullWindow = turnVirtualWindowForRange(layout, 0, input.turnIds.length);
- current = {
- sessionId: input.sessionId,
- turnIds: input.turnIds,
- window: fullWindow,
- };
- if (
- state.sessionId !== current.sessionId
- || !sameTurnIds(state.turnIds, current.turnIds)
- || !sameWindow(state.window, fullWindow)
- ) {
- setState(current);
- }
- } else if (state.sessionId !== input.sessionId || !sameTurnIds(state.turnIds, input.turnIds)) {
- const sameSession = state.sessionId === input.sessionId;
- const reconciled = sameSession
- ? reconcileTurnVirtualWindow(state.turnIds, layout, state.window, undefined, retainRange)
- : initialTurnVirtualWindow(layout, undefined, ensureIndex);
- current = {
- sessionId: input.sessionId,
- turnIds: input.turnIds,
- window: !sameSession && ensureIndex !== undefined
- && reconciled.end - reconciled.start > DEFAULT_TURN_WINDOW_SIZE
- ? initialTurnVirtualWindow(layout, DEFAULT_TURN_WINDOW_SIZE, ensureIndex)
- : reconciled,
- };
- setState(current);
- } else {
- const geometryWindow = turnVirtualWindowForRange(layout, state.window.start, state.window.end);
- if (!sameWindow(state.window, geometryWindow)) {
- current = { ...state, window: geometryWindow };
- setState(current);
- }
- }
-
- const layoutRef = useRef(layout);
- const stateRef = useRef(current);
- const pendingReveal = useRef<string | undefined>(undefined);
-
- useLayoutEffect(() => {
- layoutRef.current = layout;
- stateRef.current = current;
- if (
- ensureIndex !== undefined
- && ensureIndex >= current.window.start
- && ensureIndex < current.window.end
- ) {
- handledTargetKey.current = targetKey;
- }
- }, [current, ensureIndex, layout, targetKey]);
-
- const installWindow = useCallback((next: TurnVirtualWindow): boolean => {
- const root = input.scrollRef.current;
- if (sameWindow(stateRef.current.window, next)) return false;
- if (root) handOffExcludedInteraction(root, stateRef.current.turnIds, next);
- setState((previous) => sameWindow(previous.window, next)
- ? previous
- : { ...previous, window: next });
- return true;
- }, [input.scrollRef]);
-
- useLayoutEffect(() => {
- if (ensureIndex === undefined) return;
- const window = stateRef.current.window;
- if (ensureIndex >= window.start && ensureIndex < window.end) {
- handledTargetKey.current = targetKey;
- return;
- }
- installWindow(initialTurnVirtualWindow(layoutRef.current, undefined, ensureIndex));
- }, [ensureIndex, installWindow, targetKey]);
-
- useLayoutEffect(() => {
- pendingReveal.current = undefined;
- }, [input.sessionId]);
-
- const revealTurn = useCallback((turnId: string) => {
- const index = stateRef.current.turnIds.indexOf(turnId);
- if (index < 0) return;
- const next = initialTurnVirtualWindow(layoutRef.current, DEFAULT_TURN_WINDOW_SIZE, index);
- pendingReveal.current = turnId;
- if (!installWindow(next)) pendingReveal.current = undefined;
- }, [installWindow]);
-
- useLayoutEffect(() => {
- const root = input.scrollRef.current;
- if (!root) return;
- const reveal = pendingReveal.current;
- pendingReveal.current = undefined;
- if (reveal) {
- const target = root.querySelector<HTMLElement>(`[data-turn-id="${CSS.escape(reveal)}"]`);
- if (target) {
- target.scrollIntoView({ behavior: 'auto', block: 'start' });
- const targetIndex = stateRef.current.turnIds.indexOf(reveal);
- installWindow(turnVirtualWindowForViewport(
- layoutRef.current,
- { scrollTop: root.scrollTop, clientHeight: root.clientHeight },
- {
- ensureIndex: targetIndex < 0 ? undefined : targetIndex,
- preferredTurns: MIN_TURN_WINDOW_SIZE,
- },
- ));
- }
- }
- }, [current.window, input.scrollRef, installWindow]);
-
- useEffect(() => {
- const root = input.scrollRef.current;
- if (!root) {
- const frame = window.requestAnimationFrame(() => {
- setGeometryRevision((revision) => revision + 1);
- });
- return () => window.cancelAnimationFrame(frame);
- }
- let frame = 0;
- const updateWindow = (): void => {
- frame = 0;
- if (!virtualizationRequired || pendingReveal.current) return;
- installWindow(stableTurnVirtualWindowForViewport(
- layoutRef.current,
- stateRef.current.window,
- { scrollTop: root.scrollTop, clientHeight: root.clientHeight },
- {
- preferredTurns: MIN_TURN_WINDOW_SIZE,
- retainRange: interactiveTurnRange(root, stateRef.current.turnIds),
- },
- ));
- };
- const scheduleWindow = (): void => {
- if (!virtualizationRequired) return;
- if (frame === 0) frame = window.requestAnimationFrame(updateWindow);
- };
- const resizeObserver = new ResizeObserver((entries) => {
- const nextGap = turnLayoutGap(root, DEFAULT_TURN_GAP);
- const nextLayoutKey = turnLayoutKey(root, nextGap);
- let changed = nextLayoutKey !== layoutKey;
- for (const entry of entries) {
- const element = entry.target as HTMLElement;
- const turnId = element.dataset.virtualTurnId;
- if (!turnId) continue;
- if (input.sessionId) {
- changed = turnHeightIndex.record(
- input.sessionId,
- nextLayoutKey,
- turnId,
- entry.borderBoxSize[0]?.blockSize ?? element.getBoundingClientRect().height,
- ) || changed;
- }
- }
- if (changed) setGeometryRevision((revision) => revision + 1);
- scheduleWindow();
- });
- const observeTree = (node: Node): void => {
- if (!(node instanceof HTMLElement)) return;
- if (node.dataset.virtualTurnId) resizeObserver.observe(node);
- for (const turn of node.querySelectorAll<HTMLElement>('[data-virtual-turn-id]')) {
- resizeObserver.observe(turn);
- }
- };
- const unobserveTree = (node: Node): void => {
- if (!(node instanceof HTMLElement)) return;
- if (node.dataset.virtualTurnId) resizeObserver.unobserve(node);
- for (const turn of node.querySelectorAll<HTMLElement>('[data-virtual-turn-id]')) {
- resizeObserver.unobserve(turn);
- }
- };
- const mutationObserver = new MutationObserver((records) => {
- for (const record of records) {
- for (const node of record.removedNodes) unobserveTree(node);
- for (const node of record.addedNodes) observeTree(node);
- }
- });
- mutationObserver.observe(root, { childList: true, subtree: true });
- resizeObserver.observe(root);
- for (const turn of root.querySelectorAll<HTMLElement>('[data-virtual-turn-id]')) {
- resizeObserver.observe(turn);
- }
- root.addEventListener('scroll', scheduleWindow, { passive: true });
- scheduleWindow();
- return () => {
- if (frame !== 0) window.cancelAnimationFrame(frame);
- root.removeEventListener('scroll', scheduleWindow);
- mutationObserver.disconnect();
- resizeObserver.disconnect();
- };
- }, [input.sessionId, input.scrollRef, installWindow, layoutKey, virtualizationRequired]);
-
- return {
- start: current.window.start,
- end: current.window.end,
- beforeHeight: current.window.beforeHeight,
- afterHeight: current.window.afterHeight,
- revealTurn,
- };
-}
-
-function sameTurnIds(left: readonly string[], right: readonly string[]): boolean {
- return left.length === right.length && left.every((turnId, index) => turnId === right[index]);
-}
-
-function sameWindow(left: TurnVirtualWindow, right: TurnVirtualWindow): boolean {
- return left.start === right.start
- && left.end === right.end
- && left.beforeHeight === right.beforeHeight
- && left.afterHeight === right.afterHeight;
-}
-
-function interactiveTurnRange(
- root: HTMLElement,
- turnIds: readonly string[],
-): { start: number; end: number } | undefined {
- const indexes: number[] = [];
- const appendNode = (node: Node | null): void => {
- if (!node) return;
- const element = node instanceof Element ? node : node.parentElement;
- if (!element || !root.contains(element)) return;
- const turnId = interactionTurnId(element);
- const index = turnId === undefined ? -1 : turnIds.indexOf(turnId);
- if (index >= 0) indexes.push(index);
- };
- const active = root.ownerDocument.activeElement;
- if (active instanceof Element) appendNode(active);
- const selection = root.ownerDocument.getSelection();
- if (selection && !selection.isCollapsed) {
- appendNode(selection.anchorNode);
- appendNode(selection.focusNode);
- }
- if (indexes.length === 0) return undefined;
- return { start: Math.min(...indexes), end: Math.max(...indexes) + 1 };
-}
-
-function handOffExcludedInteraction(
- root: HTMLElement,
- turnIds: readonly string[],
- next: TurnVirtualWindow,
-): void {
- const isExcluded = (node: Node | null): boolean => {
- if (!node) return false;
- const element = node instanceof Element ? node : node.parentElement;
- if (!element || !root.contains(element)) return false;
- const turnId = interactionTurnId(element);
- const index = turnId === undefined ? -1 : turnIds.indexOf(turnId);
- return index >= 0 && (index < next.start || index >= next.end);
- };
-
- const selection = root.ownerDocument.getSelection();
- if (
- selection
- && !selection.isCollapsed
- && (isExcluded(selection.anchorNode) || isExcluded(selection.focusNode))
- ) {
- selection.removeAllRanges();
- }
-
- if (isExcluded(root.ownerDocument.activeElement)) {
- root.querySelector<HTMLElement>('.maka-chat-message-list')?.focus({ preventScroll: true });
- }
-}
-
-function interactionTurnId(element: Element): string | undefined {
- const owner = element.closest<HTMLElement>('[data-turn-id], [data-virtual-turn-id]');
- return owner?.dataset.turnId ?? owner?.dataset.virtualTurnId;
-}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org