From c4dbab16a40313b9915855fccad4c2842e397c29 Mon Sep 17 00:00:00 2001 From: Bo Liu Date: Wed, 5 Aug 2026 04:25:08 +0900 Subject: [PATCH 1/4] perf(ui): mount a switched-to transcript progressively Switching back to a long session rendered every turn in one React commit, and inserting ~22k nodes at once is a single ~0.35s frame (the #2052 trace: 147ms render, 57ms restyle, 144ms layout). The first commit now mounts only a tail window of turns; the rest arrive in idle chunks with scroll compensation, staying pinned inside the bottom lock and preserving the reading position anywhere else. The full turns array still feeds deriveTurnPresentation and the prompt rail, so presentation caching and rail geometry are unchanged. The turn-size warm-up starts once the fill completes, a rail click on a not-yet-mounted turn mounts it and then scrolls, and a search target is mounted before use-chat-scroll queries it. Closes #2052 --- .../chat-view-progressive-mount.test.tsx | 78 +++++++++++ .../__tests__/progressive-turn-mount.test.ts | 105 +++++++++++++++ packages/ui/src/chat-view.tsx | 19 ++- packages/ui/src/progressive-turn-mount.ts | 124 +++++++++++++++++ packages/ui/src/prompt-anchor-rail.tsx | 10 +- packages/ui/src/use-chat-scroll.ts | 11 +- packages/ui/src/use-progressive-turn-mount.ts | 126 ++++++++++++++++++ 7 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/__tests__/chat-view-progressive-mount.test.tsx create mode 100644 packages/ui/src/__tests__/progressive-turn-mount.test.ts create mode 100644 packages/ui/src/progressive-turn-mount.ts create mode 100644 packages/ui/src/use-progressive-turn-mount.ts diff --git a/packages/ui/src/__tests__/chat-view-progressive-mount.test.tsx b/packages/ui/src/__tests__/chat-view-progressive-mount.test.tsx new file mode 100644 index 0000000000..de0c0bb0fd --- /dev/null +++ b/packages/ui/src/__tests__/chat-view-progressive-mount.test.tsx @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { StoredMessage } from '@maka/core'; +import { ChatSurfaceLayout } from '../chat-surface-layout.js'; +import { LocaleProvider } from '../locale-context.js'; +import { ChatView } from '../chat-view.js'; +import { DEFAULT_MOUNT_WINDOW } from '../progressive-turn-mount.js'; + +const activeSession = { + id: 'session-1', + name: 'Test', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'done' as const, + backend: 'fake' as const, + llmConnectionSlug: 'fake', + connectionLocked: false, + model: 'fake', + permissionMode: 'ask' as const, +}; + +function OwnedChatView(props: ComponentProps) { + return ( + + + + + + ); +} + +function transcriptOf(turnCount: number): StoredMessage[] { + return Array.from({ length: turnCount }, (_ignored, index): StoredMessage => ({ + type: 'user', + id: `user-${index}`, + turnId: `turn-${index}`, + ts: index + 1, + text: `prompt ${index}`, + })); +} + +function mountedTurnIds(markup: string): string[] { + return [...markup.matchAll(/data-turn-id="(turn-\d+)"/g)].map((match) => match[1]!); +} + +// #2052: the first commit after opening a long session renders only the tail +// window; the idle fill that completes the transcript is behavior of the +// client runtime and is covered by the pure window tests. +describe('ChatView progressive mount', () => { + it('renders only the tail window of a long transcript in the first commit', () => { + const markup = renderToStaticMarkup( + undefined} />, + ); + const mounted = mountedTurnIds(markup); + assert.equal(mounted.length, DEFAULT_MOUNT_WINDOW.initialWindow); + assert.equal(mounted[0], `turn-${30 - DEFAULT_MOUNT_WINDOW.initialWindow}`); + assert.equal(mounted[mounted.length - 1], 'turn-29'); + }); + + it('renders a short transcript completely', () => { + const markup = renderToStaticMarkup( + undefined} />, + ); + assert.deepEqual(mountedTurnIds(markup), ['turn-0', 'turn-1', 'turn-2', 'turn-3']); + }); + + it('keeps one prompt rail tick per turn while the transcript is windowed', () => { + const markup = renderToStaticMarkup( + undefined} />, + ); + const railTicks = markup.match(/maka-prompt-rail-tick/g) ?? []; + assert.ok(railTicks.length >= 30, `expected 30 rail ticks, saw ${railTicks.length}`); + }); +}); diff --git a/packages/ui/src/__tests__/progressive-turn-mount.test.ts b/packages/ui/src/__tests__/progressive-turn-mount.test.ts new file mode 100644 index 0000000000..7a2e5f34b9 --- /dev/null +++ b/packages/ui/src/__tests__/progressive-turn-mount.test.ts @@ -0,0 +1,105 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + compensateFillScroll, + DEFAULT_MOUNT_WINDOW, + fillMountWindow, + initialMountWindow, + reconcileMountWindow, + type MountWindowState, +} from '../progressive-turn-mount.js'; + +const config = DEFAULT_MOUNT_WINDOW; + +function state(key: string | undefined, length: number, start: number): MountWindowState { + return { key, length, start }; +} + +describe('initialMountWindow', () => { + it('starts a long transcript at its tail window', () => { + assert.equal(initialMountWindow('a', 30, config).start, 30 - config.initialWindow); + }); + + it('starts a short transcript fully mounted', () => { + assert.equal(initialMountWindow('a', 4, config).start, 0); + assert.equal(initialMountWindow(undefined, 0, config).start, 0); + }); +}); + +describe('reconcileMountWindow', () => { + it('re-windows to the tail on a session switch', () => { + const next = reconcileMountWindow(state('a', 30, 0), { key: 'b', length: 30 }, config); + assert.equal(next.start, 30 - config.initialWindow); + assert.equal(next.key, 'b'); + }); + + it('re-windows when the turn count jumps by more than one window at once', () => { + const next = reconcileMountWindow(state('a', 0, 0), { key: 'a', length: 30 }, config); + assert.equal(next.start, 30 - config.initialWindow); + }); + + it('keeps the window while streaming appends single turns', () => { + const current = state('a', 30, 20); + const next = reconcileMountWindow(current, { key: 'a', length: 31 }, config); + assert.equal(next.start, 20); + }); + + it('re-windows when the transcript shrinks below the window start', () => { + const next = reconcileMountWindow(state('a', 30, 20), { key: 'a', length: 5 }, config); + assert.equal(next.start, 0); + }); + + it('widens to include an ensured index', () => { + const next = reconcileMountWindow(state('a', 30, 20), { key: 'a', length: 30 }, config, 3); + assert.equal(next.start, 3); + }); + + it('ignores an ensured index that is already mounted or unknown', () => { + const current = state('a', 30, 20); + assert.equal(reconcileMountWindow(current, { key: 'a', length: 30 }, config, 25), current); + assert.equal(reconcileMountWindow(current, { key: 'a', length: 30 }, config, -1), current); + }); + + it('returns the same reference when nothing changes', () => { + const current = state('a', 30, 20); + assert.equal(reconcileMountWindow(current, { key: 'a', length: 30 }, config), current); + }); + + it('mounts a short transcript completely from the first commit', () => { + const next = reconcileMountWindow(state(undefined, 0, 0), { key: 'a', length: 4 }, config); + assert.equal(next.start, 0); + }); +}); + +describe('fillMountWindow', () => { + it('steps the window up by one chunk until it reaches zero', () => { + let current = state('a', 30, 20); + const starts: number[] = []; + while (current.start > 0) { + current = fillMountWindow(current, config); + starts.push(current.start); + } + assert.deepEqual(starts, [16, 12, 8, 4, 0]); + assert.equal(fillMountWindow(current, config), current); + }); +}); + +describe('compensateFillScroll', () => { + it('preserves the reading position when the user has scrolled up', () => { + const result = compensateFillScroll( + { scrollTop: 1000, scrollHeight: 5000, clientHeight: 800 }, + 6000, + ); + assert.equal(result.pin, false); + assert.equal(result.scrollTop, 2000); + }); + + it('re-pins to the bottom inside the 10px lock threshold', () => { + const result = compensateFillScroll( + { scrollTop: 4195, scrollHeight: 5000, clientHeight: 800 }, + 6000, + ); + assert.equal(result.pin, true); + assert.equal(result.scrollTop, 6000); + }); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 33e71ae164..17aaee4f09 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -32,6 +32,7 @@ import { type TurnPresentationDeriver, } from './chat-turn.js'; import { useChatScroll } from './use-chat-scroll.js'; +import { useProgressiveTurnMount } from './use-progressive-turn-mount.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { SessionContextLayer } from './session-context-layer.js'; @@ -362,6 +363,19 @@ export function ChatView(props: { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); } const scrollRef = chatLayout.scrollContainerRef; + // #2052: the first commit after a session switch mounts only a tail window + // of turns; the rest arrive in idle chunks with scroll compensation. The + // full `turns` array above still feeds deriveTurnPresentation and the + // prompt rail, so presentation caching (#2030) and rail geometry are not + // window-dependent; only the JSX mapping below is sliced. + const orderedTurnIds = useMemo(() => turns.map((turn) => turn.turnId), [turns]); + const { start: mountStart, filled: turnsFilled, revealTurn } = useProgressiveTurnMount({ + sessionId: props.activeSession?.id, + turnIds: orderedTurnIds, + scrollRef, + targetTurnId: props.scrollTargetTurn?.turnId, + }); + const mountedTurns = mountStart === 0 ? turns : turns.slice(mountStart); const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, @@ -369,6 +383,7 @@ export function ChatView(props: { messages: props.messages, target: props.scrollTargetTurn, behavior: props.scrollBehavior, + warmupReady: turnsFilled, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, @@ -489,7 +504,7 @@ export function ChatView(props: { {showEmptyState ? null : ( <> {chat.length === 0 && !streamingActive ? emptyContent : null} - {turns.map((turn) => { + {mountedTurns.map((turn) => { return ( )} - + {selectionQuote && (props.onQuoteSelection || props.onAskAboutSelection) ? ( selectionActionsLayer.render(
config.initialWindow) { + start = tailStart(next.length, config); + } else if (start > next.length) { + start = tailStart(next.length, config); + } + if (ensureIndex !== undefined && ensureIndex >= 0 && ensureIndex < start) { + start = ensureIndex; + } + if (start === state.start && state.key === next.key && state.length === next.length) { + return state; + } + return { key: next.key, length: next.length, start }; +} + +/** One idle fill step: move the window up by a chunk. */ +export function fillMountWindow(state: MountWindowState, config: MountWindowConfig): MountWindowState { + if (state.start === 0) return state; + return { ...state, start: Math.max(0, state.start - config.fillChunk) }; +} + +export interface ViewportMetrics { + readonly scrollTop: number; + readonly scrollHeight: number; + readonly clientHeight: number; +} + +/** + * Scroll compensation for a fill commit. Mounting turns above the viewport + * grows the scroller upward; without compensation the content the user is + * reading slides down by the inserted height. + * + * Inside Astryx's 10px bottom-lock threshold the answer is to stay pinned, + * matching the warm-up's own finishing rule in use-chat-scroll; anywhere + * else the previous scroll offset is preserved by adding the height delta. + */ +export function compensateFillScroll( + before: ViewportMetrics, + afterScrollHeight: number, +): { pin: boolean; scrollTop: number } { + const distanceFromBottom = before.scrollHeight - before.scrollTop - before.clientHeight; + if (distanceFromBottom <= 10) { + return { pin: true, scrollTop: afterScrollHeight }; + } + return { pin: false, scrollTop: before.scrollTop + (afterScrollHeight - before.scrollHeight) }; +} diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index 94c95a7abb..c7717d28e1 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -14,6 +14,12 @@ export interface PromptAnchorRailProps { turns: readonly PromptAnchorRailTurn[]; /** The scroll container that holds the `[data-turn-id]` turn sections. */ scrollRef: RefObject; + /** + * #2052: called when a clicked turn has no `[data-turn-id]` element yet. + * The progressive mount keeps early turns out of the DOM until the idle + * fill reaches them, so the owner mounts the turn and finishes the scroll. + */ + onNavigateFallback?: (turnId: string) => void; } /** @@ -23,7 +29,7 @@ export interface PromptAnchorRailProps { * scrolls the target turn into view; an IntersectionObserver highlights the * tick whose turn is currently at the top of the viewport. */ -export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef }: PromptAnchorRailProps): React.ReactElement | null { +export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback }: PromptAnchorRailProps): React.ReactElement | null { const copy = getConversationCopy(useUiLocale()).sessions; const [activeTurnId, setActiveTurnId] = useState(null); // Rebuilding this observer costs one querySelector + observe per turn over @@ -68,6 +74,8 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const el = scrollRef.current?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); if (el && 'scrollIntoView' in el) { (el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else if (!el) { + onNavigateFallback?.(turnId); } setActiveTurnId(turnId); } diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 1216b399b8..f9ad9036f2 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -9,6 +9,13 @@ export function useChatScroll(input: { messages: readonly StoredMessage[]; target?: { turnId: string; nonce: number }; behavior?: ScrollBehavior; + /** + * #2052: false while the progressive mount is still filling the transcript. + * The warm-up snapshots the `.maka-turn` NodeList once, so starting it + * against a partial window would leave every not-yet-mounted turn at its + * 250px placeholder size for the life of the session. + */ + warmupReady?: boolean; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); @@ -40,7 +47,7 @@ export function useChatScroll(input: { const root = input.scrollRef.current; if (!root) return; root.dataset.turnWarmup = 'running'; - if (!input.hasTurns) return; + if (!input.hasTurns || input.warmupReady === false) return; let disposed = false; let cancelWarmup: (() => void) | undefined; let pollTimer: number | undefined; @@ -85,7 +92,7 @@ export function useChatScroll(input: { window.clearTimeout(settleTimer); cancelWarmup?.(); }; - }, [input.sessionId, input.hasTurns, input.scrollRef]); + }, [input.sessionId, input.hasTurns, input.warmupReady, input.scrollRef]); useEffect(() => { const target = input.target; diff --git a/packages/ui/src/use-progressive-turn-mount.ts b/packages/ui/src/use-progressive-turn-mount.ts new file mode 100644 index 0000000000..46f4cc7ee1 --- /dev/null +++ b/packages/ui/src/use-progressive-turn-mount.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; +import { + compensateFillScroll, + DEFAULT_MOUNT_WINDOW, + fillMountWindow, + initialMountWindow, + reconcileMountWindow, + type MountWindowState, + type ViewportMetrics, +} from './progressive-turn-mount.js'; +import type { WarmupScheduler } from './turn-size-warmup.js'; + +function defaultScheduler(): WarmupScheduler | undefined { + if (typeof requestAnimationFrame !== 'function') return undefined; + return { + requestIdle: typeof requestIdleCallback === 'function' + ? (callback) => { + const id = requestIdleCallback(callback); + return () => cancelIdleCallback(id); + } + : (callback) => { + const id = setTimeout(callback, 1); + return () => clearTimeout(id); + }, + requestFrame: (callback) => { + const id = requestAnimationFrame(callback); + return () => cancelAnimationFrame(id); + }, + }; +} + +/** + * React adapter for the #2052 progressive transcript mount. + * + * The window is reconciled during render, not in an effect: the first commit + * after a session switch must already be the small one, or the ~0.35s + * full-tree commit the issue measured has happened before any effect runs. + * The render-phase setState below follows React's derived-state pattern and + * terminates because reconcileMountWindow returns its input by identity once + * the state agrees with the props. + * + * Growing the window happens one idle chunk per commit: the fill effect + * schedules a single step, the commit for that step re-runs the effect, and + * the next idle callback continues until start reaches 0. Viewport metrics + * are read in the idle callback, before React commits the wider window, and + * the matching layout effect rewrites scrollTop in the same frame as that + * commit, so the fill never moves what the user is reading (or, within the + * bottom lock, keeps the transcript pinned). + */ +export function useProgressiveTurnMount(input: { + sessionId: string | undefined; + turnIds: readonly string[]; + scrollRef: RefObject; + /** Search navigation target; mounted before use-chat-scroll queries it. */ + targetTurnId?: string; + scheduler?: WarmupScheduler; +}): { + /** Render turns from this index on; 0 means the transcript is complete. */ + start: number; + /** True once every turn is mounted; gates the turn-size warm-up. */ + filled: boolean; + /** Mount a specific turn now and scroll to it once it exists. */ + revealTurn: (turnId: string) => void; +} { + const config = DEFAULT_MOUNT_WINDOW; + const [pendingReveal, setPendingReveal] = useState(undefined); + const revealId = pendingReveal ?? input.targetTurnId; + const ensureIndex = revealId === undefined ? undefined : input.turnIds.indexOf(revealId); + + const [mountWindow, setMountWindow] = useState(() => + initialMountWindow(input.sessionId, input.turnIds.length, config), + ); + const reconciled = reconcileMountWindow( + mountWindow, + { key: input.sessionId, length: input.turnIds.length }, + config, + ensureIndex, + ); + if (reconciled !== mountWindow) setMountWindow(reconciled); + + const beforeFillRef = useRef(undefined); + const schedulerRef = useRef(undefined); + if (schedulerRef.current === undefined) { + schedulerRef.current = input.scheduler ?? defaultScheduler(); + } + + useEffect(() => { + const scheduler = schedulerRef.current; + if (!scheduler || reconciled.start === 0) return; + return scheduler.requestIdle(() => { + const root = input.scrollRef.current; + beforeFillRef.current = root + ? { scrollTop: root.scrollTop, scrollHeight: root.scrollHeight, clientHeight: root.clientHeight } + : undefined; + setMountWindow((current) => fillMountWindow(current, config)); + }); + }, [reconciled.start, reconciled.key, config, input.scrollRef]); + + useLayoutEffect(() => { + const before = beforeFillRef.current; + beforeFillRef.current = undefined; + if (!before) return; + const root = input.scrollRef.current; + if (!root) return; + root.scrollTop = compensateFillScroll(before, root.scrollHeight).scrollTop; + }, [reconciled.start, input.scrollRef]); + + // A reveal request is served across two commits: the render above widens + // the window through ensureIndex, and once the element exists this effect + // performs the scroll the rail could not (its querySelector found nothing). + useEffect(() => { + if (!pendingReveal) return; + const root = input.scrollRef.current; + const element = root?.querySelector(`[data-turn-id="${CSS.escape(pendingReveal)}"]`); + if (element && 'scrollIntoView' in element) { + (element as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' }); + setPendingReveal(undefined); + } + }, [pendingReveal, reconciled.start, input.scrollRef]); + + const revealTurn = useCallback((turnId: string) => { + setPendingReveal(turnId); + }, []); + + return { start: reconciled.start, filled: reconciled.start === 0, revealTurn }; +} From 1004abe5983b943cdad0787b77aea83e58260742 Mon Sep 17 00:00:00 2001 From: Bo Liu Date: Wed, 5 Aug 2026 17:55:20 +0900 Subject: [PATCH 2/4] fix(ui): observe fill-mounted turns and clamp the empty-window boundary Review items from #2191: - the prompt rail's IntersectionObserver re-snapshots per fill step via mountedTurnsRevision, so turns mounted by the idle fill are observed and the first tick activates after scrolling to the beginning - reconcileMountWindow also re-windows when the turn count shrinks exactly to the window start; that boundary sliced to an empty transcript - the fill publishes data-progressive-fill on the scroller, and a new scroll-geometry E2E anchors on a historical turn and verifies its viewport position holds while earlier chunks mount --- apps/desktop/e2e/scroll-geometry.spec.ts | 49 ++++++++++++++++++ .../session-switch-longframe.probe.spec.ts | 51 +++++++++++++++++++ .../__tests__/progressive-turn-mount.test.ts | 12 +++++ packages/ui/src/chat-view.tsx | 7 ++- packages/ui/src/progressive-turn-mount.ts | 6 +-- packages/ui/src/prompt-anchor-rail.tsx | 15 ++++-- packages/ui/src/use-progressive-turn-mount.ts | 12 +++++ 7 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/e2e/session-switch-longframe.probe.spec.ts diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index b46d453c39..7132aee02a 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -272,3 +272,52 @@ test('the empty-chat hero centres in the reading column', async ({ window: page }); expect(offset).toBeLessThanOrEqual(1); }); + +// #2052: switching back mounts only the transcript tail; earlier turns arrive +// in idle chunks. The compensation contract: a historical turn the reader is +// anchored on must not move in the viewport while chunks mount above it. The +// unit tests pin the arithmetic; this pins the real Chromium layout and +// timing behind it. The bottom-lock path (re-pin instead of preserve) is +// already covered by the pinned settles above. +test('progressive fill preserves the reading anchor while earlier turns mount', async ({ longTranscriptWindow: page }) => { + await expect(page.locator('.maka-turn')).toHaveCount(24); + await settleGeometry(page, { pinned: true }); + + // Widen the fill window so the anchor is sampled while chunks are still + // mounting; unthrottled, an M-series host can finish the whole fill before + // the first sample. + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + + await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); + await page + .getByRole('navigation', { name: '对话列表' }) + .getByRole('button', { name: '扩展', exact: true }) + .dispatchEvent('click'); + await expect(page.locator('.maka-turn')).toHaveCount(0); + await page.getByText('超长会话滚动几何').first().dispatchEvent('click'); + + const scroller = page.locator('[data-chat-scroll-container="true"]'); + await expect(scroller).toHaveAttribute('data-progressive-fill', 'filling'); + const anchor = await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container="true"]') as HTMLElement; + // Step out of Astryx's bottom lock so preservation, not re-pinning, is + // the path under test. + root.scrollTop = Math.max(0, root.scrollTop - 600); + const turn = [...root.querySelectorAll('[data-turn-id]')].find( + (el) => el.getBoundingClientRect().top > 80, + ); + if (!turn) throw new Error('Expected a mounted turn below the topbar to anchor on'); + return { id: turn.getAttribute('data-turn-id'), top: turn.getBoundingClientRect().top }; + }); + + await expect(scroller).toHaveAttribute('data-progressive-fill', 'complete', { timeout: 20_000 }); + const after = await page.evaluate((turnId) => { + const el = document.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); + if (!el) throw new Error('Anchor turn disappeared during the fill'); + return el.getBoundingClientRect().top; + }, anchor.id as string); + expect(Math.abs(after - anchor.top)).toBeLessThanOrEqual(2); + + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); +}); diff --git a/apps/desktop/e2e/session-switch-longframe.probe.spec.ts b/apps/desktop/e2e/session-switch-longframe.probe.spec.ts new file mode 100644 index 0000000000..93461e7d09 --- /dev/null +++ b/apps/desktop/e2e/session-switch-longframe.probe.spec.ts @@ -0,0 +1,51 @@ +import { test } from './fixtures'; + +// Local measurement probe for #2052, not part of the committed suite: measures +// the longest main-thread task after switching back to the 24-turn fixture +// session. Run on main and on the fix branch with the same command; the +// numbers go into the PR's Verification section. +const SCROLLER = '[data-chat-scroll-container="true"]'; +const LONG_ROW = '[data-session-id="e2e-fixture-long-transcript"]'; + +test('probe: switch-back long frame', async ({ longTranscriptWindow: page }) => { + test.setTimeout(240_000); + await page.locator(`${SCROLLER}[data-turn-warmup="settled"]`).waitFor({ timeout: 30_000 }); + + const throttle = Number(process.env.PROBE_CPU_THROTTLE ?? '6'); + if (throttle > 1) { + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: throttle }); + console.log('CPU_THROTTLE_RATE', throttle); + } + + await page.evaluate(`(() => { + window.__lt = []; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) window.__lt.push([entry.startTime, entry.duration]); + }).observe({ entryTypes: ['longtask'] }); + })()`); + + const row = page.locator(LONG_ROW); + if (!(await row.isVisible().catch(() => false))) { + await page.locator('.maka-titlebar-action[aria-expanded="false"]').first().click(); + await row.waitFor({ state: 'visible', timeout: 10_000 }); + } + + const results: number[] = []; + for (let round = 0; round < 5; round += 1) { + await page.getByRole('button', { name: '新任务', exact: true }).click(); + await page.waitForTimeout(1000); + const t0 = (await page.evaluate('performance.now()')) as number; + await row.click(); + await page.locator(`${SCROLLER} .maka-turn`).first().waitFor({ timeout: 15_000 }); + await page.waitForTimeout(1800); + const tasks = (await page.evaluate('window.__lt')) as Array<[number, number]>; + const afterClick = tasks + .filter(([startTime]) => startTime >= t0) + .map(([startTime, duration]) => [Math.round(startTime - t0), Math.round(duration)]); + console.log(`ROUND_${round}_TASKS`, JSON.stringify(afterClick)); + const max = Math.max(0, ...afterClick.map(([, duration]) => duration)); + results.push(Math.round(max)); + } + console.log('SWITCH_BACK_MAX_LONGTASK_MS', JSON.stringify(results)); +}); diff --git a/packages/ui/src/__tests__/progressive-turn-mount.test.ts b/packages/ui/src/__tests__/progressive-turn-mount.test.ts index 7a2e5f34b9..39e2376fee 100644 --- a/packages/ui/src/__tests__/progressive-turn-mount.test.ts +++ b/packages/ui/src/__tests__/progressive-turn-mount.test.ts @@ -49,6 +49,18 @@ describe('reconcileMountWindow', () => { assert.equal(next.start, 0); }); + it('re-windows when the transcript shrinks exactly to the window start', () => { + // start === length slices to an empty transcript, just as invalid as + // start past the end (#2191 review). + const next = reconcileMountWindow(state('a', 30, 20), { key: 'a', length: 20 }, config); + assert.equal(next.start, 20 - config.initialWindow); + }); + + it('keeps an empty transcript at start zero', () => { + const current = state('a', 0, 0); + assert.equal(reconcileMountWindow(current, { key: 'a', length: 0 }, config), current); + }); + it('widens to include an ensured index', () => { const next = reconcileMountWindow(state('a', 30, 20), { key: 'a', length: 30 }, config, 3); assert.equal(next.start, 3); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 355ea764c0..a5f274279d 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -498,7 +498,12 @@ export function ChatView(props: { and a sticky box only takes an offset from its own static position onward. Rendered after the transcript it would stay parked at the bottom of the conversation until the reader scrolled there. */} - + config.initialWindow) { start = tailStart(next.length, config); - } else if (start > next.length) { + } else if (start >= next.length && next.length > 0) { start = tailStart(next.length, config); } if (ensureIndex !== undefined && ensureIndex >= 0 && ensureIndex < start) { diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index f0c36792d0..437724ec73 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -28,6 +28,12 @@ export interface PromptAnchorRailProps { * fill reaches them, so the owner mounts the turn and finishes the scroll. */ onNavigateFallback?: (turnId: string) => void; + /** + * #2052: bumped whenever turn DOM membership changes without `turns` + * changing, i.e. each idle fill step. The observer effect below re-snapshots + * on it so newly mounted turns are observed too. + */ + mountedTurnsRevision?: number; } /** @@ -44,7 +50,7 @@ export interface PromptAnchorRailProps { * against a box as tall as the conversation and scrolls away with it. See * `styles/prompt-rail.css` for the geometry the anchor establishes. */ -export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback }: PromptAnchorRailProps): React.ReactElement | null { +export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, mountedTurnsRevision }: PromptAnchorRailProps): React.ReactElement | null { const copy = getConversationCopy(useUiLocale()).sessions; const [activeTurnId, setActiveTurnId] = useState(null); // The scrollport height and the height of Astryx's sticky composer dock. @@ -60,7 +66,10 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe // keeps it from running is the caller: ChatView hands back the same array // while no rail-visible field moved. Keying the effect on that array is // therefore both the cheap check and the thing that fails loudly if the - // caller ever stops reusing it. + // caller ever stops reusing it. The progressive mount (#2052) changes turn + // DOM membership WITHOUT changing the array, so the caller also bumps + // `mountedTurnsRevision` per fill step; a bounded handful of re-snapshots + // per session switch, never per token. useEffect(() => { const root = scrollRef.current; if (!root || turns.length === 0) return; @@ -122,7 +131,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe root.removeEventListener('scroll', onScroll); if (frame !== 0) cancelAnimationFrame(frame); }; - }, [scrollRef, turns]); + }, [scrollRef, turns, mountedTurnsRevision]); useEffect(() => { const root = scrollRef.current; diff --git a/packages/ui/src/use-progressive-turn-mount.ts b/packages/ui/src/use-progressive-turn-mount.ts index 46f4cc7ee1..661b2395f5 100644 --- a/packages/ui/src/use-progressive-turn-mount.ts +++ b/packages/ui/src/use-progressive-turn-mount.ts @@ -105,6 +105,18 @@ export function useProgressiveTurnMount(input: { root.scrollTop = compensateFillScroll(before, root.scrollHeight).scrollTop; }, [reconciled.start, input.scrollRef]); + // Fill progress published the way the warm-up publishes its own terminal + // state (data-turn-warmup): tests and tooling can wait on the fill's real + // boundary instead of guessing at idle timing. + useEffect(() => { + const root = input.scrollRef.current; + if (!root) return; + root.dataset.progressiveFill = reconciled.start === 0 ? 'complete' : 'filling'; + return () => { + delete root.dataset.progressiveFill; + }; + }, [reconciled.start, input.scrollRef]); + // A reveal request is served across two commits: the render above widens // the window through ensureIndex, and once the element exists this effect // performs the scroll the rail could not (its querySelector found nothing). From 7f77b136ee51f52d3ae9a30acf083bfa25eeff5a Mon Sep 17 00:00:00 2001 From: Bo Liu Date: Wed, 5 Aug 2026 17:55:32 +0900 Subject: [PATCH 3/4] chore(e2e): drop the local measurement probe committed by accident --- .../session-switch-longframe.probe.spec.ts | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 apps/desktop/e2e/session-switch-longframe.probe.spec.ts diff --git a/apps/desktop/e2e/session-switch-longframe.probe.spec.ts b/apps/desktop/e2e/session-switch-longframe.probe.spec.ts deleted file mode 100644 index 93461e7d09..0000000000 --- a/apps/desktop/e2e/session-switch-longframe.probe.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { test } from './fixtures'; - -// Local measurement probe for #2052, not part of the committed suite: measures -// the longest main-thread task after switching back to the 24-turn fixture -// session. Run on main and on the fix branch with the same command; the -// numbers go into the PR's Verification section. -const SCROLLER = '[data-chat-scroll-container="true"]'; -const LONG_ROW = '[data-session-id="e2e-fixture-long-transcript"]'; - -test('probe: switch-back long frame', async ({ longTranscriptWindow: page }) => { - test.setTimeout(240_000); - await page.locator(`${SCROLLER}[data-turn-warmup="settled"]`).waitFor({ timeout: 30_000 }); - - const throttle = Number(process.env.PROBE_CPU_THROTTLE ?? '6'); - if (throttle > 1) { - const cdp = await page.context().newCDPSession(page); - await cdp.send('Emulation.setCPUThrottlingRate', { rate: throttle }); - console.log('CPU_THROTTLE_RATE', throttle); - } - - await page.evaluate(`(() => { - window.__lt = []; - new PerformanceObserver((list) => { - for (const entry of list.getEntries()) window.__lt.push([entry.startTime, entry.duration]); - }).observe({ entryTypes: ['longtask'] }); - })()`); - - const row = page.locator(LONG_ROW); - if (!(await row.isVisible().catch(() => false))) { - await page.locator('.maka-titlebar-action[aria-expanded="false"]').first().click(); - await row.waitFor({ state: 'visible', timeout: 10_000 }); - } - - const results: number[] = []; - for (let round = 0; round < 5; round += 1) { - await page.getByRole('button', { name: '新任务', exact: true }).click(); - await page.waitForTimeout(1000); - const t0 = (await page.evaluate('performance.now()')) as number; - await row.click(); - await page.locator(`${SCROLLER} .maka-turn`).first().waitFor({ timeout: 15_000 }); - await page.waitForTimeout(1800); - const tasks = (await page.evaluate('window.__lt')) as Array<[number, number]>; - const afterClick = tasks - .filter(([startTime]) => startTime >= t0) - .map(([startTime, duration]) => [Math.round(startTime - t0), Math.round(duration)]); - console.log(`ROUND_${round}_TASKS`, JSON.stringify(afterClick)); - const max = Math.max(0, ...afterClick.map(([, duration]) => duration)); - results.push(Math.round(max)); - } - console.log('SWITCH_BACK_MAX_LONGTASK_MS', JSON.stringify(results)); -}); From be56008d94e05d1d677ec1e81ec15b176d056a46 Mon Sep 17 00:00:00 2001 From: Bo Liu Date: Wed, 5 Aug 2026 18:21:12 +0900 Subject: [PATCH 4/4] test(e2e): make the reading-anchor watch deterministic The anchor test raced two things it could not see. Astryx's scroll handler skips direction-based unlock whenever scrollHeight changed in the same event, and the fill grows the document every step, so a scripted upward scroll almost never unlocked the bottom follower and the spring re-pinned the viewport. And any protocol round-trip between asserting the filling state and installing the watch left a window for the fill to finish first. The watch now arms inside the page before the session switch is dispatched, releases the follower through its wheel fast path plus an upward write, proves the position holds across consecutive frames before sampling, samples only while data-progressive-fill reports filling, and fails loudly when the fill races instead of passing over nothing. --- apps/desktop/e2e/scroll-geometry.spec.ts | 152 +++++++++++++++++++---- 1 file changed, 129 insertions(+), 23 deletions(-) diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index 7132aee02a..262590d67f 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -287,7 +287,7 @@ test('progressive fill preserves the reading anchor while earlier turns mount', // mounting; unthrottled, an M-series host can finish the whole fill before // the first sample. const cdp = await page.context().newCDPSession(page); - await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 20 }); await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); await page @@ -295,29 +295,135 @@ test('progressive fill preserves the reading anchor while earlier turns mount', .getByRole('button', { name: '扩展', exact: true }) .dispatchEvent('click'); await expect(page.locator('.maka-turn')).toHaveCount(0); + // Release the bottom follower the way any upward scroll does (Astryx + // unlocks on scroll direction, any source), then PROVE the reading state + // holds before measuring anything: an in-flight spring finishes its + // current animation regardless of the unlock, and fixture windows swallow + // real wheel input, so a held position is the only trustworthy signal + // that the follower is out of the picture. Once held, the anchor is + // sampled after every fill step and the watch settles the moment the fill + // completes, so turn-size warm-up inflation (out of scope here, #827 + // machinery) never enters the measurement. A completion observed with + // zero fill steps proves nothing and fails loudly instead of passing + // empty. + const watchPromise = page.evaluate( + () => + new Promise<{ maxDrift: number; fillSteps: number }>((resolveWatch, rejectWatch) => { + const root = document.querySelector('[data-chat-scroll-container="true"]') as HTMLElement; + const guard = window.setTimeout(() => { + rejectWatch(new Error('Fill did not complete while watching the anchor')); + }, 45_000); + const fail = (why: string) => { + window.clearTimeout(guard); + rejectWatch(new Error(why)); + }; + const beginWatch = () => { + // The turn being read: the first whose box still reaches below + // the topbar. + const anchor = [...root.querySelectorAll('[data-turn-id]')].find( + (el) => el.getBoundingClientRect().bottom > 120, + ); + if (!anchor) { + fail('Expected a visible turn to anchor on'); + return; + } + const baseTop = anchor.getBoundingClientRect().top; + let turnCount = root.querySelectorAll('[data-turn-id]').length; + let fillSteps = 0; + let drift = 0; + const observer = new MutationObserver(() => { + if (root.dataset.progressiveFill === 'complete') { + observer.disconnect(); + window.clearTimeout(guard); + if (fillSteps === 0) { + rejectWatch(new Error('Fill completed with no step observed; raise the throttle')); + return; + } + resolveWatch({ maxDrift: drift, fillSteps }); + return; + } + const count = root.querySelectorAll('[data-turn-id]').length; + if (count > turnCount) { + turnCount = count; + fillSteps += 1; + drift = Math.max(drift, Math.abs(anchor.getBoundingClientRect().top - baseTop)); + } + }); + observer.observe(root, { + attributes: true, + attributeFilter: ['data-progressive-fill'], + childList: true, + subtree: true, + }); + }; + // Armed before the session switch is dispatched: the hold begins in + // the same task that sets data-progressive-fill, with no protocol + // round-trip inside the fill window. + const armed = () => { + if (root.dataset.progressiveFill === 'filling') { + tryHold(); + return; + } + const armObserver = new MutationObserver(() => { + if (root.dataset.progressiveFill === 'filling') { + armObserver.disconnect(); + tryHold(); + } + }); + armObserver.observe(root, { attributes: true, attributeFilter: ['data-progressive-fill'] }); + }; + let attempts = 0; + const tryHold = () => { + if (root.dataset.progressiveFill === 'complete') { + fail('Fill completed before the anchor held; raise the throttle'); + return; + } + attempts += 1; + if (attempts > 20) { + fail('The scroller never held an unpinned position'); + return; + } + // Astryx's scroll handler skips direction detection whenever + // scrollHeight changed in the same event, and the fill grows the + // document every step, so an upward write alone rarely unlocks. + // The wheel fast path unlocks unconditionally while the spring is + // animating, and a dispatched WheelEvent reaches that listener + // even though fixture windows swallow real wheel input. + root.dispatchEvent( + new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }), + ); + root.scrollTop = Math.max(0, (root.scrollHeight - root.clientHeight) / 2); + let held = 0; + let last = root.scrollTop; + const check = () => { + if (root.dataset.progressiveFill === 'complete') { + fail('Fill completed before the anchor held; raise the throttle'); + return; + } + if (root.scrollHeight - root.scrollTop - root.clientHeight < 200) { + tryHold(); + return; + } + if (Math.abs(root.scrollTop - last) < 1) held += 1; + else { + held = 0; + last = root.scrollTop; + } + if (held >= 4) { + beginWatch(); + return; + } + requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }; + armed(); + }), + ); await page.getByText('超长会话滚动几何').first().dispatchEvent('click'); - - const scroller = page.locator('[data-chat-scroll-container="true"]'); - await expect(scroller).toHaveAttribute('data-progressive-fill', 'filling'); - const anchor = await page.evaluate(() => { - const root = document.querySelector('[data-chat-scroll-container="true"]') as HTMLElement; - // Step out of Astryx's bottom lock so preservation, not re-pinning, is - // the path under test. - root.scrollTop = Math.max(0, root.scrollTop - 600); - const turn = [...root.querySelectorAll('[data-turn-id]')].find( - (el) => el.getBoundingClientRect().top > 80, - ); - if (!turn) throw new Error('Expected a mounted turn below the topbar to anchor on'); - return { id: turn.getAttribute('data-turn-id'), top: turn.getBoundingClientRect().top }; - }); - - await expect(scroller).toHaveAttribute('data-progressive-fill', 'complete', { timeout: 20_000 }); - const after = await page.evaluate((turnId) => { - const el = document.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); - if (!el) throw new Error('Anchor turn disappeared during the fill'); - return el.getBoundingClientRect().top; - }, anchor.id as string); - expect(Math.abs(after - anchor.top)).toBeLessThanOrEqual(2); + const watch = await watchPromise; + expect(watch.fillSteps).toBeGreaterThan(0); + expect(watch.maxDrift).toBeLessThanOrEqual(2); await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); });