diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index b46d453c39..262590d67f 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -272,3 +272,158 @@ 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: 20 }); + + 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); + // 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 watch = await watchPromise; + expect(watch.fillSteps).toBeGreaterThan(0); + expect(watch.maxDrift).toBeLessThanOrEqual(2); + + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); +}); 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..39e2376fee --- /dev/null +++ b/packages/ui/src/__tests__/progressive-turn-mount.test.ts @@ -0,0 +1,117 @@ +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('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); + }); + + 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 8ffee3bf61..a5f274279d 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, @@ -483,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. */} - + {chat.length === 0 && !streamingActive ? emptyContent : null} - {turns.map((turn) => { + {mountedTurns.map((turn) => { return ( config.initialWindow) { + start = tailStart(next.length, config); + } else if (start >= next.length && next.length > 0) { + 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 2b25a0c2d9..437724ec73 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -22,6 +22,18 @@ 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; + /** + * #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; } /** @@ -38,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 }: 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. @@ -54,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; @@ -116,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; @@ -167,6 +182,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..661b2395f5 --- /dev/null +++ b/packages/ui/src/use-progressive-turn-mount.ts @@ -0,0 +1,138 @@ +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]); + + // 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). + 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 }; +}