From 981ebb5a48192c6dfc8fa7518c95030247d6899b Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:08:41 +0800 Subject: [PATCH 1/5] fix(ui): restore prompt rail scroll tracking Remove the stale virtual-turn selector left after row virtualization was deleted, and track the actual mounted Turn through one reading-band selection rule. Keep React as the sole active-attribute owner and project unsampled visible Turns into the bounded landmark rail without dropping the current tick. Generated-by: Maka --- apps/desktop/e2e/prompt-rail.spec.ts | 149 +++++++ .../src/__tests__/prompt-anchor-rail.test.ts | 116 +++++ packages/ui/src/prompt-anchor-rail.tsx | 396 ++++++++++++++---- 3 files changed, 571 insertions(+), 90 deletions(-) diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index efbcec3dd0..cf0b5a682d 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -131,6 +131,102 @@ 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}`, + }).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 +366,59 @@ 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('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..e806de042a 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,120 @@ 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', + }), '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', + }), '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', + }), 'turn-a'); +}); + +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', + }), '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', + }), '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..1bc0944d12 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,127 @@ 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; +}): string | null { + const previousRailTurnId = input.railTurns.some( + (turn) => turn.turnId === input.previousRailTurnId, + ) ? input.previousRailTurnId : null; + const fallbackRailTurnId = 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 mountedLandmarks = input.mountedTurnIds.flatMap((turnId, mountedIndex) => { + const railIndex = input.railTurns.findIndex((turn) => turn.turnId === turnId); + const sequence = railIndex === -1 ? undefined : input.railTurns[railIndex]?.sequence; + return railIndex === -1 || sequence === undefined + ? [] + : [{ mountedIndex, railIndex, sequence }]; + }); + const sequenceAnchors = [...mountedLandmarks] + .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 + : 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 [activeTurnId, setActiveTurnId] = useState(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)}"]`, - ); - 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); - } + setActiveTurnId((current) => current === turnId ? current : turnId); }, []); // 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 +381,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, + }); + })(); + 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; + let observer: IntersectionObserver; 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 +472,137 @@ 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); }; - const observer = new IntersectionObserver( - (entries) => { + const createReadingBandObserver = (rootHeight: number): IntersectionObserver => + 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 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', threshold: 0 }, - ); - for (const element of mountedTurnList.querySelectorAll('[data-turn-id]')) observeElement(element); - + }, { + root, + // IntersectionObserver resolves percentage root margins against the + // root width. A pixel margin derived from its height is what makes this + // the same top-34% band as the synchronous geometry path. + rootMargin: `0px 0px -${rootHeight * 0.66}px 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], + }); + let observerRootHeight = root.getBoundingClientRect().height; + observer = createReadingBandObserver(observerRootHeight); + for (const element of mountedTurnList.querySelectorAll('[data-transcript-turn-id]')) { + observeElement(element); + } + refreshMountedTurnOrder(); + seedReadingBandFromGeometry(); + resolveActive(); + + const rootResizeObserver = new ResizeObserver(() => { + const nextRootHeight = root.getBoundingClientRect().height; + if (nextRootHeight === observerRootHeight) return; + observerRootHeight = nextRootHeight; + observer.disconnect(); + readingBandTurnIds.clear(); + observer = createReadingBandObserver(observerRootHeight); + for (const element of idByElement.keys()) observer.observe(element); + seedReadingBandFromGeometry(); + resolveActive(); + }); + rootResizeObserver.observe(root); + + let membershipFrame = 0; + let membershipFramesLeft = 0; + const settleMembershipGeometry = (): void => { + membershipFrame = requestAnimationFrame(() => { + membershipFrame = 0; + seedReadingBandFromGeometry(); + resolveActive(); + 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 }); @@ -404,11 +618,13 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe return () => { observer.disconnect(); + rootResizeObserver.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 +664,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 +741,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 = From c15387572e35101874048e69d15fba9bc4f26736 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:09:02 +0800 Subject: [PATCH 2/5] fix(ui): refine prompt rail fallbacks Use the nearest mounted prompt before the durable landmark index arrives, keep the reading band aligned with Chromium percentage margins, and give transcript history paging the established timeout. Generated-by: Maka --- apps/desktop/e2e/prompt-rail.spec.ts | 1 + .../src/__tests__/prompt-anchor-rail.test.ts | 12 +++ packages/ui/src/prompt-anchor-rail.tsx | 74 ++++++++----------- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index cf0b5a682d..4f1858e245 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -220,6 +220,7 @@ async function scrollTranscriptThroughHistory(page: Page): Promise { 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); diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index e806de042a..2a3bc6867b 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -318,6 +318,18 @@ test('one-sided sequence extrapolation cannot skip past the adjacent tick', () = }), '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, + }), 'turn-b'); +}); + test('a window without a sampled wrapper preserves its previous current tick', () => { assert.equal(selectPromptRailTickForMountedTurn({ activeTurnId: 'active', diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index 1bc0944d12..e43f4fbb6c 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -290,14 +290,23 @@ export function selectPromptRailTickForMountedTurn(input: { if (direct) return direct.turnId; const activeIndex = input.mountedTurnIds.indexOf(input.activeTurnId); if (activeIndex === -1) return fallbackRailTurnId; - const mountedLandmarks = input.mountedTurnIds.flatMap((turnId, mountedIndex) => { + const mountedRailTurns = input.mountedTurnIds.flatMap((turnId, mountedIndex) => { const railIndex = input.railTurns.findIndex((turn) => turn.turnId === turnId); - const sequence = railIndex === -1 ? undefined : input.railTurns[railIndex]?.sequence; - return railIndex === -1 || sequence === undefined - ? [] - : [{ mountedIndex, railIndex, sequence }]; + return railIndex === -1 ? [] : [{ mountedIndex, railIndex }]; }); - const sequenceAnchors = [...mountedLandmarks] + 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, @@ -308,7 +317,7 @@ export function selectPromptRailTickForMountedTurn(input: { if (!firstAnchor || !secondAnchor) { return firstAnchor ? input.railTurns[firstAnchor.railIndex]?.turnId ?? null - : fallbackRailTurnId; + : nearestMountedRailTurnId ?? fallbackRailTurnId; } const activeSequence = firstAnchor.sequence + (secondAnchor.sequence - firstAnchor.sequence) @@ -458,7 +467,6 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const mountedTurnList = messageList?.firstElementChild; if (!root || !mountedTurnList || orderedTurnIds.length === 0) return; - let observer: IntersectionObserver; const idByElement = new Map(); let mountedTurnIndexById = new Map(); const readingBandTurnIds = new Set(); @@ -537,27 +545,21 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe if (active !== null) markActiveTurn(active); }; - const createReadingBandObserver = (rootHeight: number): IntersectionObserver => - 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, - // IntersectionObserver resolves percentage root margins against the - // root width. A pixel margin derived from its height is what makes this - // the same top-34% band as the synchronous geometry path. - rootMargin: `0px 0px -${rootHeight * 0.66}px 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], - }); - let observerRootHeight = root.getBoundingClientRect().height; - observer = createReadingBandObserver(observerRootHeight); + 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); } @@ -565,19 +567,6 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe seedReadingBandFromGeometry(); resolveActive(); - const rootResizeObserver = new ResizeObserver(() => { - const nextRootHeight = root.getBoundingClientRect().height; - if (nextRootHeight === observerRootHeight) return; - observerRootHeight = nextRootHeight; - observer.disconnect(); - readingBandTurnIds.clear(); - observer = createReadingBandObserver(observerRootHeight); - for (const element of idByElement.keys()) observer.observe(element); - seedReadingBandFromGeometry(); - resolveActive(); - }); - rootResizeObserver.observe(root); - let membershipFrame = 0; let membershipFramesLeft = 0; const settleMembershipGeometry = (): void => { @@ -618,7 +607,6 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe return () => { observer.disconnect(); - rootResizeObserver.disconnect(); mutationObserver.disconnect(); root.removeEventListener('scroll', onScroll); if (membershipFrame !== 0) cancelAnimationFrame(membershipFrame); From 3b34ec08018d6d35053697bb31db8ba16a63e20e Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:12:42 +0800 Subject: [PATCH 3/5] fix(ui): preserve tail prompt fallback Carry the tail state with the active transcript Turn so a prompt-less resident window selects the final rail landmark instead of the first. Generated-by: Maka --- .../src/__tests__/prompt-anchor-rail.test.ts | 19 ++++++++++++++++++ packages/ui/src/prompt-anchor-rail.tsx | 20 ++++++++++++++----- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index 2a3bc6867b..26eac1c61d 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -285,6 +285,7 @@ test('an unsampled mounted Turn maps through the two nearest durable landmarks', mountedTurnIds: ['turn-66', 'turn-67', 'turn-68', 'turn-69'], railTurns, previousRailTurnId: 'turn-67', + atEnd: false, }), 'turn-65'); }); @@ -300,6 +301,7 @@ test('uneven sequence gaps choose a nearby real landmark instead of a linear tic mountedTurnIds: ['turn-b', 'active', 'turn-c'], railTurns, previousRailTurnId: 'turn-b', + atEnd: false, }), 'turn-c'); }); @@ -315,6 +317,7 @@ test('one-sided sequence extrapolation cannot skip past the adjacent tick', () = mountedTurnIds: ['active', 'turn-b', 'turn-c'], railTurns, previousRailTurnId: 'turn-d', + atEnd: false, }), 'turn-a'); }); @@ -327,6 +330,20 @@ test('a prompt-less mounted tail uses the nearest loaded prompt before the index { 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'); }); @@ -339,6 +356,7 @@ test('a window without a sampled wrapper preserves its previous current tick', ( { turnId: 'turn-b', label: '', sequence: 10 }, ], previousRailTurnId: 'turn-a', + atEnd: false, }), 'turn-a'); }); @@ -351,6 +369,7 @@ test('a window without landmarks replaces a stale current with a current rail ti { turnId: 'turn-b', label: '', sequence: 10 }, ], previousRailTurnId: 'stale-turn', + atEnd: false, }), 'turn-a'); }); diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index e43f4fbb6c..a78de69d54 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -281,11 +281,14 @@ export function selectPromptRailTickForMountedTurn(input: { 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 = previousRailTurnId ?? input.railTurns[0]?.turnId ?? 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); @@ -368,15 +371,21 @@ export function selectPromptRailTickForMountedTurn(input: { /** 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 [activeTurnId, setActiveTurnId] = useState(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) => { - setActiveTurnId((current) => current === turnId ? current : turnId); + const markActiveTurn = useCallback((turnId: string, atEnd = false) => { + setActiveSelection((current) => + current?.turnId === turnId && current.atEnd === atEnd ? current : { turnId, atEnd }, + ); }, []); // 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 @@ -429,6 +438,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe mountedTurnIds, railTurns, previousRailTurnId: previousActiveRailTurnIdRef.current, + atEnd: activeSelection?.atEnd ?? false, }); })(); const activeRailTurnId = mappedActiveRailTurnId @@ -542,7 +552,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe : [], turnIndexById: mountedTurnIndexById, }); - if (active !== null) markActiveTurn(active); + if (active !== null) markActiveTurn(active, atEnd); }; const observer = new IntersectionObserver((entries) => { From 198137f39f65a337d26dd184ff3b74bd8dd08dda Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:34:17 +0800 Subject: [PATCH 4/5] test(desktop): pin prompt rail observer lifetime Generated-by: Maka --- apps/desktop/e2e/prompt-rail.spec.ts | 74 +++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 4f1858e245..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; @@ -420,6 +420,78 @@ test('manual transcript scrolling keeps exactly the visible prompt current', asy 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, }) => { From 6af2c7e19aae7adf36bdfb3afaff36a39ba6ac1c Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:58:32 +0800 Subject: [PATCH 5/5] chore(ci): retrigger checks Generated-by: Maka