diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index efbcec3dd0..7af51a35c2 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -19,7 +19,7 @@ import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; -import { ensureSidebarExpanded, expect, test } from './fixtures'; +import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; import type { Page } from '@playwright/test'; const MAX_PROMPT_RAIL_TICKS = 64; @@ -131,6 +131,103 @@ function notifyTranscriptScrolled(page: Page): Promise { }); } +interface ActivePromptRailSnapshot { + currentIds: string[]; + expectedId: string | null; + sourceTurnId: string | null; +} + +async function activePromptRailSnapshot(page: Page): Promise { + return page.evaluate(async ({ promptCount }) => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + if (!root) throw new Error('the chat scroll container is missing'); + const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; + const currentIds = ticks + .filter((tick) => tick.getAttribute('aria-current') === 'true') + .map((tick) => tick.dataset.promptTurnId ?? ''); + const rootBounds = root.getBoundingClientRect(); + const atEnd = root.scrollHeight - root.scrollTop - root.clientHeight <= 2; + const turns = [...root.querySelectorAll('[data-transcript-turn-id]')] + .map((turn) => ({ + element: turn, + id: turn.dataset.transcriptTurnId ?? '', + index: Number(turn.dataset.transcriptTurnId?.split('-').at(-1)) - 1, + })) + .filter((turn) => turn.id.length > 0 && Number.isFinite(turn.index)); + const readingBandTurns = turns + .filter(({ element }) => { + const bounds = element.getBoundingClientRect(); + return bounds.bottom > rootBounds.top + && bounds.top < rootBounds.top + rootBounds.height * 0.34; + }) + .sort((left, right) => left.index - right.index); + const scrollportTurns = turns + .filter(({ element }) => { + const bounds = element.getBoundingClientRect(); + return bounds.bottom > rootBounds.top && bounds.top < rootBounds.bottom; + }) + .sort((left, right) => left.index - right.index); + const sourceTurn = atEnd + ? turns.reduce( + (latest, turn) => latest === null || turn.index > latest.index ? turn : latest, + null, + ) + : readingBandTurns[0] ?? scrollportTurns[0] ?? null; + const expectedRailIndex = sourceTurn === null || ticks.length === 0 + ? null + : Math.round( + sourceTurn.index * (ticks.length - 1) / (promptCount - 1), + ); + const expectedId = expectedRailIndex === null + ? null + : ticks[expectedRailIndex]?.dataset.promptTurnId ?? null; + return { + currentIds, + expectedId, + sourceTurnId: sourceTurn?.id ?? null, + }; + }, { promptCount: PROMPT_RAIL_PROMPT_COUNT }); +} + +async function expectPromptRailMatchesReadingPosition(page: Page): Promise { + let lastSnapshot: ActivePromptRailSnapshot | null = null; + try { + await expect.poll(async () => { + lastSnapshot = await activePromptRailSnapshot(page); + return lastSnapshot.expectedId !== null + && lastSnapshot.currentIds.length === 1 + && lastSnapshot.currentIds[0] === lastSnapshot.expectedId; + }, { message: 'the one current tick maps from the Turn being read' }).toBe(true); + } catch { + throw new Error(`the prompt rail did not settle on the reading position: ${JSON.stringify(lastSnapshot)}`); + } + const snapshot = await activePromptRailSnapshot(page); + expect(snapshot.expectedId, `no visible Turn in ${JSON.stringify(snapshot)}`).not.toBeNull(); + expect(snapshot.currentIds).toEqual([snapshot.expectedId]); +} + +async function scrollTranscriptThroughHistory(page: Page): Promise { + for (let pageIndex = 0; pageIndex < PROMPT_RAIL_PROMPT_COUNT; pageIndex += 1) { + const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + if (firstBefore === 'turn-prompt-rail-1') return; + await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + root.dispatchEvent(new Event('scroll')); + }); + await expect.poll(async () => + page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), { + message: `history loads before ${firstBefore}`, + timeout: 20_000, + }).not.toBe(firstBefore); + await waitForPaintedFrames(page); + await expectPromptRailMatchesReadingPosition(page); + } + throw new Error('the first prompt did not enter the active transcript range'); +} + 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. @@ -270,6 +367,131 @@ test('the first click of a session lands on its prompt and holds', async ({ expect(settled?.tickIsCurrent).toBe(true); }); +test('manual transcript scrolling keeps exactly the visible prompt current', async ({ + promptRailWindow: page, +}) => { + await page.setViewportSize({ width: 1_000, height: 700 }); + await scrollTranscriptTo(page, 'bottom'); + await expectPromptRailMatchesReadingPosition(page); + await expect(page.locator('.maka-prompt-rail-tick[aria-current="true"]')).toHaveCount(1); + await expect(page.locator('.maka-prompt-rail-tick').last()).toHaveAttribute( + 'aria-current', + 'true', + ); + + await page.evaluate(() => { + const rail = document.querySelector('.maka-prompt-rail'); + if (!rail) throw new Error('the prompt rail is missing'); + const counts: number[] = []; + const record = () => counts.push( + rail.querySelectorAll('.maka-prompt-rail-tick[aria-current="true"]').length, + ); + const observer = new MutationObserver(record); + observer.observe(rail, { + attributes: true, + subtree: true, + attributeFilter: ['aria-current'], + }); + record(); + Object.assign(window, { + __makaPromptRailCurrentCounts: counts, + __makaPromptRailCurrentObserver: observer, + }); + }); + + await scrollTranscriptThroughHistory(page); + await scrollTranscriptTo(page, 'top'); + await expectPromptRailMatchesReadingPosition(page); + await expect(page.locator('.maka-prompt-rail-tick[aria-current="true"]')).toHaveCount(1); + await expect(page.locator('.maka-prompt-rail-tick').first()).toHaveAttribute( + 'aria-current', + 'true', + ); + + const currentCounts = await page.evaluate(() => { + const state = window as Window & { + __makaPromptRailCurrentCounts?: number[]; + __makaPromptRailCurrentObserver?: MutationObserver; + }; + state.__makaPromptRailCurrentObserver?.disconnect(); + return state.__makaPromptRailCurrentCounts ?? []; + }); + expect(currentCounts.length).toBeGreaterThan(1); + expect(currentCounts.every((count) => count === 1), currentCounts.join(',')).toBe(true); +}); + +test('streaming deltas do not reconstruct the prompt rail observer', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + const sendAndSettle = async (prompt: string, expectedTurns: number): Promise => { + await composer.fill(prompt); + await composer.press('Enter'); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(expectedTurns, { + timeout: 20_000, + }); + }; + await sendAndSettle('First prompt rail observer seed', 1); + await sendAndSettle('Second prompt rail observer seed', 2); + + await page.evaluate(() => { + const NativeIntersectionObserver = window.IntersectionObserver; + const state: { + constructions: number; + initialConstructions: number | null; + } = { constructions: 0, initialConstructions: null }; + window.IntersectionObserver = class extends NativeIntersectionObserver { + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { + super(callback, options); + if ( + options?.root === document.querySelector('[data-chat-scroll-container="true"]') + && options.rootMargin === '0px 0px -66% 0px' + ) { + state.constructions += 1; + state.initialConstructions ??= state.constructions; + } + } + }; + Object.assign(window, { __makaPromptRailObserverProbe: state }); + }); + + const streamingPrompt = Array.from( + { length: 40 }, + (_, index) => `Observer stability line ${index + 1}`, + ).join('\n'); + await composer.fill(streamingPrompt); + await composer.press('Enter'); + + await expect.poll(() => page.evaluate(() => ( + window as Window & { + __makaPromptRailObserverProbe?: { constructions: number }; + } + ).__makaPromptRailObserverProbe?.constructions ?? 0), { + message: 'the third Turn creates the prompt rail observer', + }).toBeGreaterThan(0); + + // The fake backend emits nine characters per delta, so reaching the last + // line proves many same-Turn text updates landed after observer creation. + await expect(page.getByRole('log').getByText( + /Fake backend received:[\s\S]*Observer stability line 40/, + )).toBeVisible({ + timeout: 20_000, + }); + + const settled = await page.evaluate(() => ({ ...( + window as Window & { + __makaPromptRailObserverProbe: { + constructions: number; + initialConstructions: number | null; + }; + } + ).__makaPromptRailObserverProbe })); + expect(settled.constructions).toBe(settled.initialConstructions); +}); + test('active transcript Turns keep stable DOM identities while scrolling', async ({ promptRailWindow: page, }) => { diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index b1e806c628..26eac1c61d 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -27,6 +27,8 @@ import { mergePromptAnchorRailTurns, observeActivePromptRailVisibility, PromptAnchorRail, + selectPromptRailActiveTurn, + selectPromptRailTickForMountedTurn, type PromptRailFrameScheduler, } from '../prompt-anchor-rail.js'; @@ -226,6 +228,151 @@ test('a jump gives the transcript back the moment the reader touches it', () => harness.restore(); }); +const turnIndexById = new Map([ + ['turn-1', 0], + ['turn-2', 1], + ['turn-3', 2], + ['turn-4', 3], +]); + +test('the transcript tail selects the latest mounted Turn', () => { + assert.equal(selectPromptRailActiveTurn({ + atEnd: true, + mountedTurnIds: ['turn-3', 'turn-1', 'turn-4'], + readingBandTurnIds: ['turn-1'], + scrollportTurnIds: ['turn-1', 'turn-3'], + turnIndexById, + }), 'turn-4'); +}); + +test('the reading band selects its earliest Turn by transcript order', () => { + assert.equal(selectPromptRailActiveTurn({ + atEnd: false, + mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], + readingBandTurnIds: ['turn-4', 'turn-2', 'turn-3'], + scrollportTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], + turnIndexById, + }), 'turn-2'); +}); + +test('an empty reading band falls back to the earliest Turn in the scrollport', () => { + assert.equal(selectPromptRailActiveTurn({ + atEnd: false, + mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], + readingBandTurnIds: [], + scrollportTurnIds: ['turn-4', 'turn-3'], + turnIndexById, + }), 'turn-3'); +}); + +test('no eligible Turn leaves the selection unresolved', () => { + assert.equal(selectPromptRailActiveTurn({ + atEnd: false, + mountedTurnIds: ['unknown-turn'], + readingBandTurnIds: [], + scrollportTurnIds: [], + turnIndexById, + }), null); +}); + +test('an unsampled mounted Turn maps through the two nearest durable landmarks', () => { + const railTurns = Array.from({ length: 64 }, (_, railIndex) => { + const turnIndex = Math.round(railIndex * 119 / 63); + return { turnId: `turn-${turnIndex + 1}`, label: '', sequence: turnIndex * 2 }; + }); + assert.equal(selectPromptRailTickForMountedTurn({ + activeTurnId: 'turn-66', + mountedTurnIds: ['turn-66', 'turn-67', 'turn-68', 'turn-69'], + railTurns, + previousRailTurnId: 'turn-67', + atEnd: false, + }), 'turn-65'); +}); + +test('uneven sequence gaps choose a nearby real landmark instead of a linear tick position', () => { + const railTurns = [ + { turnId: 'turn-a', label: '', sequence: 0 }, + { turnId: 'turn-b', label: '', sequence: 10 }, + { turnId: 'turn-c', label: '', sequence: 1_000 }, + { turnId: 'turn-d', label: '', sequence: 1_010 }, + ]; + assert.equal(selectPromptRailTickForMountedTurn({ + activeTurnId: 'active', + mountedTurnIds: ['turn-b', 'active', 'turn-c'], + railTurns, + previousRailTurnId: 'turn-b', + atEnd: false, + }), 'turn-c'); +}); + +test('one-sided sequence extrapolation cannot skip past the adjacent tick', () => { + const railTurns = [ + { turnId: 'turn-a', label: '', sequence: 0 }, + { turnId: 'turn-b', label: '', sequence: 10 }, + { turnId: 'turn-c', label: '', sequence: 1_000 }, + { turnId: 'turn-d', label: '', sequence: 1_010 }, + ]; + assert.equal(selectPromptRailTickForMountedTurn({ + activeTurnId: 'active', + mountedTurnIds: ['active', 'turn-b', 'turn-c'], + railTurns, + previousRailTurnId: 'turn-d', + atEnd: false, + }), 'turn-a'); +}); + +test('a prompt-less mounted tail uses the nearest loaded prompt before the index arrives', () => { + assert.equal(selectPromptRailTickForMountedTurn({ + activeTurnId: 'active', + mountedTurnIds: ['turn-a', 'turn-b', 'active'], + railTurns: [ + { turnId: 'turn-a', label: '' }, + { turnId: 'turn-b', label: '' }, + ], + previousRailTurnId: null, + atEnd: true, + }), 'turn-b'); +}); + +test('a prompt-less tail without a mounted landmark uses the final rail tick', () => { + assert.equal(selectPromptRailTickForMountedTurn({ + activeTurnId: 'active', + mountedTurnIds: ['active'], + railTurns: [ + { turnId: 'turn-a', label: '', sequence: 0 }, + { turnId: 'turn-b', label: '', sequence: 10 }, + ], + previousRailTurnId: null, + atEnd: true, + }), 'turn-b'); +}); + +test('a window without a sampled wrapper preserves its previous current tick', () => { + assert.equal(selectPromptRailTickForMountedTurn({ + activeTurnId: 'active', + mountedTurnIds: ['active', 'neighbor'], + railTurns: [ + { turnId: 'turn-a', label: '', sequence: 0 }, + { turnId: 'turn-b', label: '', sequence: 10 }, + ], + previousRailTurnId: 'turn-a', + atEnd: false, + }), 'turn-a'); +}); + +test('a window without landmarks replaces a stale current with a current rail tick', () => { + assert.equal(selectPromptRailTickForMountedTurn({ + activeTurnId: 'active', + mountedTurnIds: ['active'], + railTurns: [ + { turnId: 'turn-a', label: '', sequence: 0 }, + { turnId: 'turn-b', label: '', sequence: 10 }, + ], + previousRailTurnId: 'stale-turn', + atEnd: false, + }), 'turn-a'); +}); + test('keeps the active tick visible when the rail viewport resizes', () => { let railBox = box(0, 600); let tickBox = box(570, 590); diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index 473abd2274..a78de69d54 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -45,6 +45,8 @@ const HOVER_FALLOFF_TICKS = 3; */ const PREVIEW_DELAY_MS = 120; const MAX_PROMPT_RAIL_TICKS = 64; +/** Distinguish a positive IO overlap from Chromium's zero-area edge contact. */ +const POSITIVE_INTERSECTION_RATIO = 0.000_001; /** Quiet frames at the destination that end a jump's hold. */ const JUMP_SETTLE_QUIET_FRAMES = 3; @@ -245,38 +247,145 @@ export interface PromptAnchorRailProps { onNavigateStart?: (() => void) | undefined; } +export function selectPromptRailActiveTurn(input: { + atEnd: boolean; + mountedTurnIds: Iterable; + readingBandTurnIds: Iterable; + scrollportTurnIds: Iterable; + turnIndexById: ReadonlyMap; +}): string | null { + const readingBandTurnIds = [...input.readingBandTurnIds]; + const candidates = input.atEnd + ? input.mountedTurnIds + : readingBandTurnIds.length > 0 + ? readingBandTurnIds + : input.scrollportTurnIds; + let selected: string | null = null; + let selectedIndex = input.atEnd ? -1 : Number.POSITIVE_INFINITY; + for (const turnId of candidates) { + const index = input.turnIndexById.get(turnId); + if (index === undefined) continue; + if ( + (input.atEnd && index > selectedIndex) + || (!input.atEnd && index < selectedIndex) + ) { + selected = turnId; + selectedIndex = index; + } + } + return selected; +} + +export function selectPromptRailTickForMountedTurn(input: { + activeTurnId: string; + mountedTurnIds: readonly string[]; + railTurns: readonly PromptAnchorRailTurn[]; + previousRailTurnId: string | null; + atEnd: boolean; +}): string | null { + const previousRailTurnId = input.railTurns.some( + (turn) => turn.turnId === input.previousRailTurnId, + ) ? input.previousRailTurnId : null; + const fallbackRailTurnId = input.atEnd + ? input.railTurns.at(-1)?.turnId ?? null + : previousRailTurnId ?? input.railTurns[0]?.turnId ?? null; + const direct = input.railTurns.find((turn) => turn.turnId === input.activeTurnId); + if (direct) return direct.turnId; + const activeIndex = input.mountedTurnIds.indexOf(input.activeTurnId); + if (activeIndex === -1) return fallbackRailTurnId; + const mountedRailTurns = input.mountedTurnIds.flatMap((turnId, mountedIndex) => { + const railIndex = input.railTurns.findIndex((turn) => turn.turnId === turnId); + return railIndex === -1 ? [] : [{ mountedIndex, railIndex }]; + }); + const nearestMountedRailTurn = [...mountedRailTurns] + .sort((left, right) => + Math.abs(left.mountedIndex - activeIndex) - Math.abs(right.mountedIndex - activeIndex) + || left.mountedIndex - right.mountedIndex, + )[0]; + const nearestMountedRailTurnId = nearestMountedRailTurn + ? input.railTurns[nearestMountedRailTurn.railIndex]?.turnId ?? null + : null; + const sequenceAnchors = mountedRailTurns + .flatMap(({ mountedIndex, railIndex }) => { + const sequence = input.railTurns[railIndex]?.sequence; + return sequence === undefined ? [] : [{ mountedIndex, railIndex, sequence }]; + }) + .sort((left, right) => + Math.abs(left.mountedIndex - activeIndex) - Math.abs(right.mountedIndex - activeIndex) + || left.mountedIndex - right.mountedIndex, + ) + .slice(0, 2) + .sort((left, right) => left.mountedIndex - right.mountedIndex); + const [firstAnchor, secondAnchor] = sequenceAnchors; + if (!firstAnchor || !secondAnchor) { + return firstAnchor + ? input.railTurns[firstAnchor.railIndex]?.turnId ?? null + : nearestMountedRailTurnId ?? fallbackRailTurnId; + } + const activeSequence = firstAnchor.sequence + + (secondAnchor.sequence - firstAnchor.sequence) + * (activeIndex - firstAnchor.mountedIndex) + / (secondAnchor.mountedIndex - firstAnchor.mountedIndex); + const firstSequence = input.railTurns[0]?.sequence; + const lastSequence = input.railTurns[input.railTurns.length - 1]?.sequence; + const projectedRailIndex = firstSequence !== undefined + && lastSequence !== undefined + && lastSequence > firstSequence + ? Math.round( + (activeSequence - firstSequence) + * (input.railTurns.length - 1) + / (lastSequence - firstSequence), + ) + : firstAnchor.railIndex; + let selected: PromptAnchorRailTurn | null = null; + let selectedIndex = -1; + let selectedDistance = Number.POSITIVE_INFINITY; + const candidateRange = activeIndex < firstAnchor.mountedIndex + ? [Math.max(0, firstAnchor.railIndex - 1), firstAnchor.railIndex] + : activeIndex > secondAnchor.mountedIndex + ? [ + secondAnchor.railIndex, + Math.min(input.railTurns.length - 1, secondAnchor.railIndex + 1), + ] + : [firstAnchor.railIndex, secondAnchor.railIndex]; + for (let index = 0; input.railTurns.length > index; index += 1) { + if (index < candidateRange[0]! || index > candidateRange[1]!) continue; + const turn = input.railTurns[index]!; + if (turn.sequence === undefined) continue; + const distance = Math.abs(turn.sequence - activeSequence); + if ( + distance < selectedDistance + || ( + distance === selectedDistance + && Math.abs(index - projectedRailIndex) < Math.abs(selectedIndex - projectedRailIndex) + ) + ) { + selected = turn; + selectedIndex = index; + selectedDistance = distance; + } + } + return selected?.turnId ?? fallbackRailTurnId; +} + /** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart }: PromptAnchorRailProps): React.ReactElement | null { const copy = getConversationCopy(useUiLocale()).sessions; - const activeTurnIdRef = useRef(null); + const [activeSelection, setActiveSelection] = useState<{ + turnId: string; + atEnd: boolean; + } | null>(null); + const activeTurnId = activeSelection?.turnId ?? null; + const [mountedTurnIds, setMountedTurnIds] = useState([]); const [safeArea, setSafeArea] = useState<{ scrollport: number; dock: number } | null>(null); const railRef = useRef(null); + const previousActiveRailTurnIdRef = useRef(null); const [hoveredIndex, setHoveredIndex] = useState(null); const activeVisibilityFrame = useRef(0); - const markActiveTurn = useCallback((turnId: string) => { - if (activeTurnIdRef.current === turnId) return; - activeTurnIdRef.current = turnId; - const rail = railRef.current; - const previous = rail?.querySelector('[data-active="true"]'); - previous?.removeAttribute('data-active'); - previous?.removeAttribute('aria-current'); - const target = rail?.querySelector( - `[data-prompt-turn-id="${CSS.escape(turnId)}"]`, + const markActiveTurn = useCallback((turnId: string, atEnd = false) => { + setActiveSelection((current) => + current?.turnId === turnId && current.atEnd === atEnd ? current : { turnId, atEnd }, ); - target?.setAttribute('data-active', 'true'); - target?.setAttribute('aria-current', 'true'); - if (activeVisibilityFrame.current !== 0) cancelAnimationFrame(activeVisibilityFrame.current); - activeVisibilityFrame.current = requestAnimationFrame(() => { - activeVisibilityFrame.current = requestAnimationFrame(() => { - activeVisibilityFrame.current = 0; - if (rail) keepActivePromptRailTickVisible(rail); - }); - }); - }, []); - useEffect(() => () => { - if (activeVisibilityFrame.current !== 0) { - cancelAnimationFrame(activeVisibilityFrame.current); - } }, []); // Identified by a sequence number rather than a boolean so a second click // during a jump starts its own claim instead of inheriting what is left of @@ -290,35 +399,90 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const jumpTargetRef = useRef(null); const onNavigateStartRef = useRef(onNavigateStart); onNavigateStartRef.current = onNavigateStart; - const turnIndexById = useMemo( - () => new Map(turns.map((turn, index) => [turn.turnId, index])), - [turns], - ); - const railTurns = useMemo(() => { - if (turns.length <= MAX_PROMPT_RAIL_TICKS) return turns; + // Prompt/reply text changes while an answer streams, but the scroll spy only + // depends on Turn identity and order. Keep that structural value stable so a + // text delta does not tear down and rebuild every transcript observer. + const orderedTurnIdsRef = useRef([]); + const nextOrderedTurnIds = turns.map((turn) => turn.turnId); + if ( + orderedTurnIdsRef.current.length !== nextOrderedTurnIds.length + || nextOrderedTurnIds.some((turnId, index) => orderedTurnIdsRef.current[index] !== turnId) + ) { + orderedTurnIdsRef.current = nextOrderedTurnIds; + } + const orderedTurnIds = orderedTurnIdsRef.current; + const railTurnIndexes = useMemo(() => { + if (orderedTurnIds.length <= MAX_PROMPT_RAIL_TICKS) { + return orderedTurnIds.map((_, index) => index); + } return Array.from({ length: MAX_PROMPT_RAIL_TICKS }, (_, index) => - turns[Math.round(index * (turns.length - 1) / (MAX_PROMPT_RAIL_TICKS - 1))]!, + Math.round(index * (orderedTurnIds.length - 1) / (MAX_PROMPT_RAIL_TICKS - 1)), ); - }, [turns]); - - const railTurnIdFor = (turnId: string): string | null => { - const turnIndex = turnIndexById.get(turnId); - if (turnIndex === undefined) return null; - if (turns.length === railTurns.length) return turnId; - const railIndex = Math.round(turnIndex * (railTurns.length - 1) / (turns.length - 1)); - return railTurns[railIndex]?.turnId ?? null; - }; + }, [orderedTurnIds]); + const railTurnIds = useMemo( + () => railTurnIndexes.map((turnIndex) => orderedTurnIds[turnIndex]!), + [orderedTurnIds, railTurnIndexes], + ); + const railTurns = railTurnIndexes.map((turnIndex) => turns[turnIndex]!); + const mappedActiveRailTurnId = (() => { + if (activeTurnId === null) return null; + if (railTurnIds.includes(activeTurnId)) return activeTurnId; + const orderedActiveIndex = orderedTurnIds.indexOf(activeTurnId); + if (orderedActiveIndex !== -1 && orderedTurnIds.length > railTurnIds.length) { + return railTurnIds[Math.round( + orderedActiveIndex * (railTurnIds.length - 1) / (orderedTurnIds.length - 1), + )] ?? null; + } + return selectPromptRailTickForMountedTurn({ + activeTurnId, + mountedTurnIds, + railTurns, + previousRailTurnId: previousActiveRailTurnIdRef.current, + atEnd: activeSelection?.atEnd ?? false, + }); + })(); + const activeRailTurnId = mappedActiveRailTurnId + ?? (railTurnIds.includes(previousActiveRailTurnIdRef.current ?? '') + ? previousActiveRailTurnIdRef.current + : null); + useEffect(() => { + if (activeRailTurnId !== null) previousActiveRailTurnIdRef.current = activeRailTurnId; + }, [activeRailTurnId]); + + // React is the only writer of the active attributes. Once that render has + // committed, bring the current tick into the rail's own bounded viewport. + useEffect(() => { + const rail = railRef.current; + if (!rail || activeRailTurnId === null) return; + if (activeVisibilityFrame.current !== 0) cancelAnimationFrame(activeVisibilityFrame.current); + activeVisibilityFrame.current = requestAnimationFrame(() => { + activeVisibilityFrame.current = requestAnimationFrame(() => { + activeVisibilityFrame.current = 0; + keepActivePromptRailTickVisible(rail); + }); + }); + return () => { + if (activeVisibilityFrame.current !== 0) { + cancelAnimationFrame(activeVisibilityFrame.current); + activeVisibilityFrame.current = 0; + } + }; + }, [activeRailTurnId]); useEffect(() => { const root = scrollRef.current; - const mountedTurnList = root?.querySelector('[data-virtual-turn-id]')?.parentElement; - if (!root || !mountedTurnList || turns.length === 0) return; + const messageList = root?.querySelector('.maka-chat-message-list'); + // Astryx ChatMessageList renders one inner flex column as its first child; + // that column is the direct parent of Maka's keyed transcript Turn wrappers. + const mountedTurnList = messageList?.firstElementChild; + if (!root || !mountedTurnList || orderedTurnIds.length === 0) return; const idByElement = new Map(); - const visible = new Set(); + let mountedTurnIndexById = new Map(); + const readingBandTurnIds = new Set(); const observeElement = (element: Element): void => { - const turnId = element.getAttribute('data-turn-id'); - if (!turnId || !turnIndexById.has(turnId) || idByElement.has(element)) return; + const turnId = element.getAttribute('data-transcript-turn-id'); + if (!turnId || idByElement.has(element)) return; idByElement.set(element, turnId); observer.observe(element); }; @@ -326,69 +490,118 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const turnId = idByElement.get(element); if (!turnId) return; idByElement.delete(element); - visible.delete(turnId); + readingBandTurnIds.delete(turnId); observer.unobserve(element); }; const visitTurnElements = (node: Node, visit: (element: Element) => void): void => { if (!(node instanceof Element)) return; - if (node.hasAttribute('data-turn-id')) visit(node); - for (const element of node.querySelectorAll('[data-turn-id]')) visit(element); + if (node.hasAttribute('data-transcript-turn-id')) visit(node); + for (const element of node.querySelectorAll('[data-transcript-turn-id]')) visit(element); + }; + const refreshMountedTurnOrder = (): void => { + const nextMountedTurnIds = [...mountedTurnList.querySelectorAll( + '[data-transcript-turn-id]', + )].flatMap((element) => { + const turnId = element.getAttribute('data-transcript-turn-id'); + return turnId ? [turnId] : []; + }); + mountedTurnIndexById = new Map( + nextMountedTurnIds.map((turnId, index) => [turnId, index]), + ); + setMountedTurnIds((current) => + current.length === nextMountedTurnIds.length + && nextMountedTurnIds.every((turnId, index) => current[index] === turnId) + ? current + : nextMountedTurnIds, + ); + }; + const turnIdsIntersecting = (top: number, bottom: number): string[] => { + const turnIds: string[] = []; + for (const [element, turnId] of idByElement) { + const bounds = element.getBoundingClientRect(); + if (bounds.bottom > top && bounds.top < bottom) turnIds.push(turnId); + } + return turnIds; }; - const activeFor = (turnId: string | null): void => { - if (turnId === null) return; - const railTurnId = railTurnIdFor(turnId); - if (railTurnId !== null) markActiveTurn(railTurnId); + const seedReadingBandFromGeometry = (): void => { + const rootBounds = root.getBoundingClientRect(); + readingBandTurnIds.clear(); + for (const turnId of turnIdsIntersecting( + rootBounds.top, + rootBounds.top + rootBounds.height * 0.34, + )) { + readingBandTurnIds.add(turnId); + } }; const resolveActive = (): void => { // A jump owns the highlight until its scroll settles. Without this the // observer walks the highlight through every prompt the scroll passes, // which is the travelling the click was meant to skip. if (jumpTargetRef.current !== null) return; - if (root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX) { - let latest: string | null = null; - let latestIndex = -1; - for (const turnId of idByElement.values()) { - const index = turnIndexById.get(turnId) ?? -1; - if (index > latestIndex) { - latest = turnId; - latestIndex = index; - } - } - activeFor(latest); - return; - } - let firstVisible: string | null = null; - let firstIndex = Number.POSITIVE_INFINITY; - for (const turnId of visible) { - const index = turnIndexById.get(turnId) ?? Number.POSITIVE_INFINITY; - if (index < firstIndex) { - firstVisible = turnId; - firstIndex = index; - } - } - activeFor(firstVisible); + const atEnd = + root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX; + const rootBounds = !atEnd && readingBandTurnIds.size === 0 + ? root.getBoundingClientRect() + : null; + const active = selectPromptRailActiveTurn({ + atEnd, + mountedTurnIds: idByElement.values(), + readingBandTurnIds, + scrollportTurnIds: rootBounds !== null + ? turnIdsIntersecting(rootBounds.top, rootBounds.bottom) + : [], + turnIndexById: mountedTurnIndexById, + }); + if (active !== null) markActiveTurn(active, atEnd); }; - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - const id = idByElement.get(entry.target); - if (!id) continue; - if (entry.isIntersecting) visible.add(id); - else visible.delete(id); - } + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const turnId = idByElement.get(entry.target); + if (!turnId) continue; + if (entry.intersectionRect.height > 0) readingBandTurnIds.add(turnId); + else readingBandTurnIds.delete(turnId); + } + resolveActive(); + }, { + root, + rootMargin: '0px 0px -66% 0px', + // The positive threshold delivers a callback when an overlap becomes + // a zero-area boundary touch, which the strict geometry rule excludes. + threshold: [0, POSITIVE_INTERSECTION_RATIO], + }); + for (const element of mountedTurnList.querySelectorAll('[data-transcript-turn-id]')) { + observeElement(element); + } + refreshMountedTurnOrder(); + seedReadingBandFromGeometry(); + resolveActive(); + + let membershipFrame = 0; + let membershipFramesLeft = 0; + const settleMembershipGeometry = (): void => { + membershipFrame = requestAnimationFrame(() => { + membershipFrame = 0; + seedReadingBandFromGeometry(); resolveActive(); - }, - { root, rootMargin: '0px 0px -66% 0px', threshold: 0 }, - ); - for (const element of mountedTurnList.querySelectorAll('[data-turn-id]')) observeElement(element); - + membershipFramesLeft -= 1; + if (membershipFramesLeft > 0) settleMembershipGeometry(); + }); + }; const mutationObserver = new MutationObserver((records) => { for (const record of records) { for (const node of record.removedNodes) visitTurnElements(node, unobserveElement); for (const node of record.addedNodes) visitTurnElements(node, observeElement); } - resolveActive(); + refreshMountedTurnOrder(); + // Browser scroll anchoring and the paged transcript projection can land + // across several frames after the child-list mutation. Follow that short + // settle window, or a prepended page can leave its previous boundary + // Turn current after the replacement is being read. + membershipFramesLeft = 6; + if (membershipFrame === 0) { + settleMembershipGeometry(); + } }); mutationObserver.observe(mountedTurnList, { childList: true }); @@ -406,9 +619,10 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe observer.disconnect(); mutationObserver.disconnect(); root.removeEventListener('scroll', onScroll); + if (membershipFrame !== 0) cancelAnimationFrame(membershipFrame); if (frame !== 0) cancelAnimationFrame(frame); }; - }, [markActiveTurn, scrollRef, turnIndexById, railTurns]); + }, [markActiveTurn, orderedTurnIds, scrollRef]); useEffect(() => { const root = scrollRef.current; @@ -448,7 +662,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const rail = railRef.current; if (!rail) return; return observeActivePromptRailVisibility(rail); - }, [turns]); + }, [orderedTurnIds]); // A click owns the highlight until the destination settles, so the scroll it // started cannot walk the active tick through every prompt on the way. Keyed @@ -525,7 +739,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe onPointerLeave={() => setHoveredIndex(null)} > {railTurns.map((turn, index) => { - const isActive = turn.turnId === activeTurnIdRef.current; + const isActive = turn.turnId === activeRailTurnId; const preview = turn.label.trim() || copy.emptyPrompt; const replyPreview = (turn.reply ?? '').replace(/\s+/g, ' ').trim().slice(0, 140); const proximity =