From 6fb588d3edcc7023fe58c9a9df82af24e8bf19d0 Mon Sep 17 00:00:00 2001 From: limit_yan Date: Sat, 4 Jul 2026 11:37:50 +0800 Subject: [PATCH] fix(flow-chat): stabilize turn navigation ownership --- .../components/modern/FlowChatHeader.test.tsx | 29 ++- .../components/modern/FlowChatHeader.tsx | 12 +- ...rnFlowChatContainer.history-state.test.tsx | 178 +++++++++++++--- .../modern/ModernFlowChatContainer.tsx | 43 ++-- .../components/modern/VirtualMessageList.tsx | 30 ++- .../modern/useFlowChatFollowOutput.test.tsx | 194 ++++++++++++++++++ .../modern/useFlowChatFollowOutput.ts | 16 +- .../scripts/perf-coverage-contract.test.mjs | 2 + .../run-long-session-interaction-matrix.mjs | 13 +- .../l1-chat-turn-navigation-release.spec.ts | 9 + 10 files changed, 462 insertions(+), 64 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index b819142e6..36b625eb2 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -92,8 +92,8 @@ describe('FlowChatHeader', () => { container.remove(); }); - it('keeps the turn list open until the selected turn becomes current', () => { - const onJumpToTurn = vi.fn(); + it('closes the turn list as soon as a different turn selection is accepted', () => { + const onJumpToTurn = vi.fn(() => true); const initialProps = createProps({ onJumpToTurn }); act(() => { @@ -117,7 +117,7 @@ describe('FlowChatHeader', () => { }); expect(onJumpToTurn).toHaveBeenCalledWith('turn-2'); - expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(container.querySelector('[role="dialog"]')).toBeNull(); act(() => { root.render(); @@ -127,7 +127,7 @@ describe('FlowChatHeader', () => { }); it('closes the turn list and notifies the container when selecting the current turn', () => { - const onJumpToTurn = vi.fn(); + const onJumpToTurn = vi.fn(() => true); act(() => { root.render(); @@ -146,4 +146,25 @@ describe('FlowChatHeader', () => { expect(onJumpToTurn).toHaveBeenCalledWith('turn-1'); expect(container.querySelector('[role="dialog"]')).toBeNull(); }); + + it('keeps the turn list open when the container rejects the selection', () => { + const onJumpToTurn = vi.fn(() => false); + + act(() => { + root.render(); + }); + + const turnListButton = container.querySelector('[data-testid="flowchat-header-turn-list"]'); + act(() => { + turnListButton?.click(); + }); + + const turnItems = Array.from(container.querySelectorAll('.flowchat-header__turn-list-item')); + act(() => { + turnItems[1]?.click(); + }); + + expect(onJumpToTurn).toHaveBeenCalledWith('turn-2'); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 6040b4aa1..b7735e693 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -54,8 +54,8 @@ export interface FlowChatHeaderProps { sessionId?: string; /** Ordered turn summaries used by header navigation. */ turns?: FlowChatHeaderTurnSummary[]; - /** Jump to a specific turn. */ - onJumpToTurn?: (turnId: string) => void; + /** Jump to a specific turn. Return false when the selection is rejected or still pending. */ + onJumpToTurn?: (turnId: string) => boolean | void; /** Jump to the currently displayed turn. */ onJumpToCurrentTurn?: () => void; /** Jump to the previous turn. */ @@ -313,14 +313,10 @@ export const FlowChatHeader: React.FC = ({ const handleTurnSelect = (turnId: string) => { if (!onJumpToTurn) return; - const selectedTurn = displayTurns.find(turn => turn.turnId === turnId); - if (selectedTurn?.turnIndex === currentTurn) { - onJumpToTurn(turnId); + const accepted = onJumpToTurn(turnId); + if (accepted !== false) { setIsTurnListOpen(false); - return; } - - onJumpToTurn(turnId); }; const handleSubagentSelect = (sessionId: string) => { diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index 4efc23b16..84a28afff 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -30,6 +30,7 @@ const virtualListMock = vi.hoisted(() => ({ isTurnRenderedInViewport: vi.fn(() => false), isTurnTextRenderedInViewport: vi.fn(() => false), pinTurnToTop: vi.fn(() => true), + pinTurnToTopWithStatus: vi.fn(() => 'settled' as const), })); const virtualListActionClickMock = vi.hoisted(() => vi.fn()); const startupTraceMock = vi.hoisted(() => ({ @@ -264,6 +265,8 @@ describe('ModernFlowChatContainer historical empty state', () => { virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); virtualListMock.pinTurnToTop.mockReset(); virtualListMock.pinTurnToTop.mockReturnValue(true); + virtualListMock.pinTurnToTopWithStatus.mockReset(); + virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); virtualListActionClickMock.mockReset(); startupTraceMock.markPhase.mockReset(); historySessionDiagnosticsMock.beginHistorySessionDiagnostics.mockReset(); @@ -890,7 +893,7 @@ describe('ModernFlowChatContainer historical empty state', () => { (headerPropsMock.latest?.onJumpToPreviousTurn as (() => void) | undefined)?.(); }); - expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-99', { + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-99', { behavior: 'smooth', pinMode: 'transient', }); @@ -915,7 +918,7 @@ describe('ModernFlowChatContainer historical empty state', () => { totalTurns: 2, userMessage: 'Latest prompt', }; - virtualListMock.pinTurnToTop.mockReturnValue(false); + virtualListMock.pinTurnToTopWithStatus.mockReturnValue('rejected'); await act(async () => { root.render(); @@ -926,11 +929,13 @@ describe('ModernFlowChatContainer historical empty state', () => { totalTurns: 2, }); + let initialAccepted = true; await act(async () => { - (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => void) | undefined)?.('turn-1'); + initialAccepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? true; }); - expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-1', { + expect(initialAccepted).toBe(false); + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { behavior: 'smooth', pinMode: 'transient', }); @@ -939,7 +944,7 @@ describe('ModernFlowChatContainer historical empty state', () => { totalTurns: 2, }); - virtualListMock.pinTurnToTop.mockReturnValue(true); + virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); stateMocks.virtualItems = [ ...stateMocks.virtualItems, ]; @@ -949,7 +954,7 @@ describe('ModernFlowChatContainer historical empty state', () => { }); flushAnimationFrame(); - expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-1', { + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { behavior: 'auto', pinMode: 'transient', }); @@ -975,7 +980,7 @@ describe('ModernFlowChatContainer historical empty state', () => { }); }); - it('continues retrying an accepted header turn selection until the viewport reports the target turn', async () => { + it('delegates accepted header turn selections to the list without container-level retry', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -994,17 +999,19 @@ describe('ModernFlowChatContainer historical empty state', () => { totalTurns: 2, userMessage: 'Latest prompt', }; - virtualListMock.pinTurnToTop.mockReturnValue(true); + virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); await act(async () => { root.render(); }); + let accepted = false; await act(async () => { - (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => void) | undefined)?.('turn-1'); + accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? false; }); - expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-1', { + expect(accepted).toBe(true); + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { behavior: 'smooth', pinMode: 'transient', }); @@ -1013,13 +1020,9 @@ describe('ModernFlowChatContainer historical empty state', () => { totalTurns: 2, }); - const acceptedCallCount = virtualListMock.pinTurnToTop.mock.calls.length; + const acceptedCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; flushAnimationFrame(); - expect(virtualListMock.pinTurnToTop.mock.calls.length).toBeGreaterThan(acceptedCallCount); - expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-1', { - behavior: 'auto', - pinMode: 'transient', - }); + expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(acceptedCallCount); stateMocks.visibleTurnInfo = { turnId: 'turn-1', @@ -1036,12 +1039,12 @@ describe('ModernFlowChatContainer historical empty state', () => { currentTurn: 1, totalTurns: 2, }); - const settledCallCount = virtualListMock.pinTurnToTop.mock.calls.length; + const settledCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; flushAnimationFrame(); - expect(virtualListMock.pinTurnToTop.mock.calls.length).toBe(settledCallCount); + expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(settledCallCount); }); - it('cancels pending header turn retry when the user scrolls manually', async () => { + it('leaves internally pending turn pins with the list instead of retrying from the container', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1060,23 +1063,152 @@ describe('ModernFlowChatContainer historical empty state', () => { totalTurns: 2, userMessage: 'Latest prompt', }; - virtualListMock.pinTurnToTop.mockReturnValue(true); + virtualListMock.pinTurnToTopWithStatus.mockReturnValue('pending'); + + await act(async () => { + root.render(); + }); + + let accepted = true; + await act(async () => { + accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? true; + }); + + expect(accepted).toBe(false); + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { + behavior: 'smooth', + pinMode: 'transient', + }); + const pendingCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; + flushAnimationFrame(); + flushAnimationFrame(); + expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(pendingCallCount); + }); + + it('rejects stale header turn selections without issuing a pin request', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-1', 'Older prompt'), + createTurn('turn-2', 'Latest prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, + { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest prompt' } }, + ]; + stateMocks.visibleTurnInfo = { + turnId: 'turn-2', + turnIndex: 2, + totalTurns: 2, + userMessage: 'Latest prompt', + }; + + await act(async () => { + root.render(); + }); + + const beforeSelectionCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; + let accepted = true; + await act(async () => { + accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-missing') ?? true; + }); + + expect(accepted).toBe(false); + expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount); + }); + + it('keeps long-session header turn selections single-shot after the list accepts the pin', async () => { + const turns = Array.from({ length: 25 }, (_, index) => { + const turnNumber = index + 1; + return createTurn(`turn-${turnNumber}`, `Prompt ${turnNumber}`); + }); + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: turns, + } as Partial); + stateMocks.virtualItems = turns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: { id: `user-${turn.id}`, content: turn.userMessage.content }, + })); + stateMocks.visibleTurnInfo = { + turnId: 'turn-25', + turnIndex: 25, + totalTurns: 25, + userMessage: 'Prompt 25', + }; + virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); + + await act(async () => { + root.render(); + }); + + expect(headerPropsMock.latest).toMatchObject({ + currentTurn: 25, + totalTurns: 25, + }); + expect(headerPropsMock.latest?.turns).toHaveLength(25); + + const beforeSelectionCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; + let accepted = false; + await act(async () => { + accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-7') ?? false; + }); + + expect(accepted).toBe(true); + expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount + 1); + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-7', { + behavior: 'smooth', + pinMode: 'transient', + }); + + flushAnimationFrame(); + flushAnimationFrame(); + flushAnimationFrame(); + expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount + 1); + }); + + it('cancels not-yet-accepted header turn retry when the user scrolls manually', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-1', 'Older prompt'), + createTurn('turn-2', 'Latest prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, + { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest prompt' } }, + ]; + stateMocks.visibleTurnInfo = { + turnId: 'turn-2', + turnIndex: 2, + totalTurns: 2, + userMessage: 'Latest prompt', + }; + virtualListMock.pinTurnToTopWithStatus.mockReturnValue('rejected'); await act(async () => { root.render(); }); + let accepted = true; await act(async () => { - (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => void) | undefined)?.('turn-1'); + accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? true; }); + expect(accepted).toBe(false); expect(headerPropsMock.latest).toMatchObject({ currentTurn: 2, totalTurns: 2, }); flushAnimationFrame(); - const retryCallCount = virtualListMock.pinTurnToTop.mock.calls.length; + const retryCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; expect(retryCallCount).toBeGreaterThan(1); await act(async () => { @@ -1088,7 +1220,7 @@ describe('ModernFlowChatContainer historical empty state', () => { }); flushAnimationFrame(); - expect(virtualListMock.pinTurnToTop.mock.calls.length).toBe(retryCallCount); + expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(retryCallCount); }); it('does not expose previous navigation before the loaded tail range in partial history', async () => { diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index f637a428d..5ae218c9a 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -8,7 +8,11 @@ import { useTranslation } from 'react-i18next'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; import { useSessionModeStore } from '@/app/stores/sessionModeStore'; -import { VirtualMessageList, VirtualMessageListRef } from './VirtualMessageList'; +import { + VirtualMessageList, + type FlowChatTurnPinRequestStatus, + type VirtualMessageListRef, +} from './VirtualMessageList'; import { FlowChatHeader, type FlowChatHeaderCommandSummary, @@ -245,7 +249,7 @@ export const ModernFlowChatContainer: React.FC = ( const releasedHistoryCompletionKeyRef = useRef(null); const visibleTurnInfoRef = useRef(visibleTurnInfo); const turnSummariesRef = useRef([]); - const requestHeaderTurnPinRef = useRef<((turnId: string, behavior?: ScrollBehavior) => boolean) | null>(null); + const requestHeaderTurnPinRef = useRef<((turnId: string, behavior?: ScrollBehavior) => FlowChatTurnPinRequestStatus) | null>(null); const virtualListRef = useRef(null); const chatScopeRef = useRef(null); const [historyInitialContentReadyKey, setHistoryInitialContentReadyKey] = useState(null); @@ -664,17 +668,17 @@ export const ModernFlowChatContainer: React.FC = ( } }, [pendingHeaderTurnId, turnSummaries, visibleTurnInfo?.turnId]); - const requestHeaderTurnPin = useCallback((turnId: string, behavior: ScrollBehavior = 'smooth') => { + const requestHeaderTurnPin = useCallback((turnId: string, behavior: ScrollBehavior = 'smooth'): FlowChatTurnPinRequestStatus => { const isLatestTurn = turnSummaries[turnSummaries.length - 1]?.turnId === turnId; const targetTurn = findDialogTurn(activeSession?.dialogTurns, turnId); const pinMode = isLatestTurn && shouldUseStickyLatestPin(targetTurn) ? 'sticky-latest' : 'transient'; - return virtualListRef.current?.pinTurnToTop(turnId, { + return virtualListRef.current?.pinTurnToTopWithStatus(turnId, { behavior, pinMode, - }) ?? false; + }) ?? 'rejected'; }, [activeSession?.dialogTurns, turnSummaries]); useEffect(() => { requestHeaderTurnPinRef.current = requestHeaderTurnPin; @@ -707,9 +711,11 @@ export const ModernFlowChatContainer: React.FC = ( return; } - const accepted = requestHeaderTurnPinRef.current?.(queuedHeaderTurnPinId, 'auto') ?? false; - if (accepted) { - setPendingHeaderTurnId(queuedHeaderTurnPinId); + const pinStatus = requestHeaderTurnPinRef.current?.(queuedHeaderTurnPinId, 'auto') ?? 'rejected'; + if (pinStatus === 'settled' || pinStatus === 'pending') { + setQueuedHeaderTurnPinId(null); + setPendingHeaderTurnId(null); + return; } attempts += 1; @@ -1003,24 +1009,31 @@ export const ModernFlowChatContainer: React.FC = ( }, [searchCurrentMatchVirtualIndex]); const handleJumpToTurn = useCallback((turnId: string) => { - if (!turnId) return; + if (!turnId) return false; const targetStillExists = turnSummaries.some(turn => turn.turnId === turnId); if (!targetStillExists) { setQueuedHeaderTurnPinId(null); setPendingHeaderTurnId(null); - return; + return false; } - const accepted = requestHeaderTurnPin(turnId); - if (accepted) { - setQueuedHeaderTurnPinId(turnId); - setPendingHeaderTurnId(turnId); - return; + const pinStatus = requestHeaderTurnPin(turnId); + if (pinStatus === 'settled') { + setQueuedHeaderTurnPinId(null); + setPendingHeaderTurnId(null); + return true; + } + + if (pinStatus === 'pending') { + setQueuedHeaderTurnPinId(null); + setPendingHeaderTurnId(null); + return false; } setQueuedHeaderTurnPinId(turnId); setPendingHeaderTurnId(null); + return false; }, [requestHeaderTurnPin, turnSummaries]); const handleJumpToPreviousTurn = useCallback(() => { diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 6689388a4..dede04dcc 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -93,6 +93,8 @@ type InitialHistoryTransitionState = { /** * Methods exposed by VirtualMessageList. */ +export type FlowChatTurnPinRequestStatus = 'rejected' | 'pending' | 'settled'; + export interface VirtualMessageListRef { scrollToTurn: (turnIndex: number) => void; scrollToIndex: (index: number) => void; @@ -108,6 +110,8 @@ export interface VirtualMessageListRef { scrollToLatestEndPosition: () => void; // Aligns the target turn's user message to the viewport top. pinTurnToTop: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => boolean; + // Detailed status for callers that must distinguish immediate feedback from deferred virtual-list settling. + pinTurnToTopWithStatus: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => FlowChatTurnPinRequestStatus; } export interface VirtualMessageListProps { @@ -3195,21 +3199,21 @@ const VirtualMessageListSession = forwardRef { + const requestTurnPinToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }): FlowChatTurnPinRequestStatus => { const requestedPinMode = options?.pinMode ?? 'transient'; const requestedBehavior = options?.behavior ?? 'auto'; const targetTurn = findDialogTurn(activeSession?.dialogTurns, turnId); if (requestedPinMode === 'sticky-latest' && !shouldUseStickyLatestPin(targetTurn)) { - return false; + return 'rejected'; } const targetItem = userMessageItems.find(({ item }) => item.turnId === turnId); if (!targetItem) { - return false; + return 'rejected'; } if (!virtuosoRef.current) { if (!useStaticInitialHistoryList) { - return false; + return 'rejected'; } pendingStaticTurnPinRef.current = { @@ -3226,11 +3230,11 @@ const VirtualMessageListSession = forwardRef { + const pinTurnToTopWithStatus = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }): FlowChatTurnPinRequestStatus => { const shouldExitFollowOutput = !( options?.pinMode === 'sticky-latest' && turnId === latestTurnId @@ -3581,6 +3585,10 @@ const VirtualMessageListSession = forwardRef { + return pinTurnToTopWithStatus(turnId, options) !== 'rejected'; + }, [pinTurnToTopWithStatus]); + const visibleTurnInfo = useModernFlowChatStore(state => state.visibleTurnInfo); const handleJumpToCurrentTurn = useCallback(() => { @@ -3805,10 +3813,12 @@ const VirtualMessageListSession = forwardRef; + +function setScrollerMetrics( + scroller: HTMLElement, + metrics: { scrollHeight: number; clientHeight: number; scrollTop: number }, +): void { + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: metrics.scrollHeight }, + clientHeight: { configurable: true, value: metrics.clientHeight }, + scrollTop: { configurable: true, writable: true, value: metrics.scrollTop }, + }); +} + +function Harness({ + scroller, + onController, + performAutoFollowScroll, +}: { + scroller: HTMLElement; + onController: (controller: FollowOutputController) => void; + performAutoFollowScroll: () => void; +}) { + const scrollerRef = React.useRef(scroller); + scrollerRef.current = scroller; + + const controller = useFlowChatFollowOutput({ + activeSessionId: 'session-1', + latestTurnId: 'turn-2', + virtualItemCount: 20, + isStreaming: true, + scrollerRef, + performUserFollowScroll: vi.fn(), + performAutoFollowScroll, + performLatestTurnStickyPin: vi.fn(), + }); + + onController(controller); + return
; +} + +describe('useFlowChatFollowOutput', () => { + let container: HTMLDivElement; + let root: Root; + let controller: FollowOutputController | null; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + controller = null; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + void callback; + return 1; + })); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.unstubAllGlobals(); + }); + + it('exits output follow immediately when explicit user scroll intent is already away from bottom', () => { + const scroller = document.createElement('div'); + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const performAutoFollowScroll = vi.fn(() => { + scroller.scrollTop = 1000; + }); + + act(() => { + root.render( + { + controller = nextController; + }} + performAutoFollowScroll={performAutoFollowScroll} + />, + ); + }); + + act(() => { + controller?.enterFollowOutput('auto-follow'); + }); + + expect(controller?.isFollowingOutput).toBe(true); + + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 600, + }); + + act(() => { + controller?.handleUserScrollIntent(); + }); + + expect(controller?.isFollowingOutput).toBe(false); + }); + + it('exits output follow for explicit upward intent before browser scroll metrics move', () => { + const scroller = document.createElement('div'); + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const performAutoFollowScroll = vi.fn(() => { + scroller.scrollTop = 1000; + }); + + act(() => { + root.render( + { + controller = nextController; + }} + performAutoFollowScroll={performAutoFollowScroll} + />, + ); + }); + + act(() => { + controller?.enterFollowOutput('auto-follow'); + }); + + expect(controller?.isFollowingOutput).toBe(true); + + act(() => { + controller?.handleUserScrollIntent(); + }); + + expect(controller?.isFollowingOutput).toBe(false); + }); + + it('cancels armed auto-follow when upward intent arrives during the programmatic guard', () => { + const scroller = document.createElement('div'); + setScrollerMetrics(scroller, { + scrollHeight: 1500, + clientHeight: 500, + scrollTop: 1000, + }); + const performAutoFollowScroll = vi.fn(() => { + scroller.scrollTop = 1000; + }); + + act(() => { + root.render( + { + controller = nextController; + }} + performAutoFollowScroll={performAutoFollowScroll} + />, + ); + }); + + act(() => { + controller?.armFollowOutputForNewTurn(); + }); + + expect(controller?.isFollowingOutput).toBe(false); + + act(() => { + controller?.handleUserScrollIntent(); + }); + + let activated = true; + act(() => { + activated = controller?.activateArmedFollowOutput() ?? true; + }); + + expect(activated).toBe(false); + expect(controller?.isFollowingOutput).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index 64c27e5cd..e9c8e1d50 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -298,7 +298,11 @@ export function useFlowChatFollowOutput({ ? getDistanceFromBottom(scroller) > AUTO_FOLLOW_BOTTOM_THRESHOLD_PX : false; - if (!alreadyAwayFromBottom) { + if ( + !alreadyAwayFromBottom && + !isFollowingOutputRef.current && + armedAutoFollowTurnIdRef.current === null + ) { return; } @@ -308,7 +312,15 @@ export function useFlowChatFollowOutput({ ); } explicitUserScrollIntentUntilMsRef.current = now + USER_SCROLL_INTENT_WINDOW_MS; - }, [scrollerRef]); + + if (isFollowingOutputRef.current) { + // Input handlers see the upward intent before scrollTop necessarily moves. + exitFollowOutput('user-scroll-up'); + return; + } + + cancelPendingAutoFollowArm(); + }, [cancelPendingAutoFollowArm, exitFollowOutput, scrollerRef]); const scheduleFollowToLatest = useCallback((_reason: string) => { if ( diff --git a/tests/e2e/scripts/perf-coverage-contract.test.mjs b/tests/e2e/scripts/perf-coverage-contract.test.mjs index 7998b2a06..b4d54c50c 100644 --- a/tests/e2e/scripts/perf-coverage-contract.test.mjs +++ b/tests/e2e/scripts/perf-coverage-contract.test.mjs @@ -40,6 +40,8 @@ test('performance scripts expose focused startup stability and interaction profi assert.match(interactionRunner, /BITFUN_E2E_PERF_MATRIX_PROFILE/); assert.match(interactionRunner, /first-scroll/); assert.match(interactionRunner, /resize-window-width/); + assert.match(interactionRunner, /turn-navigation/); + assert.match(interactionRunner, /l1-chat-turn-navigation-release\.spec\.ts/); assert.match(interactionRunner, /BITFUN_E2E_PERF_RAPID_SWITCH_DELAY_MS/); assert.match(interactionRunner, /BITFUN_E2E_PERF_ALLOW_MISSING_REPORTS/); assert.match(interactionRunner, /expected performance report was not written/); diff --git a/tests/e2e/scripts/run-long-session-interaction-matrix.mjs b/tests/e2e/scripts/run-long-session-interaction-matrix.mjs index 00802a7fd..be51270d6 100644 --- a/tests/e2e/scripts/run-long-session-interaction-matrix.mjs +++ b/tests/e2e/scripts/run-long-session-interaction-matrix.mjs @@ -52,6 +52,14 @@ const scenarios = { BITFUN_E2E_PERF_POST_VISIBLE_INTERACTION: 'scroll-down', }, }, + 'turn-navigation': { + spec: './specs/l1-chat-turn-navigation-release.spec.ts', + reportPrefix: null, + env: { + BITFUN_E2E_TURN_NAV_SESSION_ID: DEFAULT_LONG_SESSION_TARGET_ID, + BITFUN_E2E_TURN_NAV_TARGET_INDEX: '20', + }, + }, 'resize-window': { spec: './specs/performance/startup-session-perf.spec.ts', grep: 'collects first-open timing for a generated long session', @@ -75,8 +83,8 @@ const scenarios = { }; const profiles = { - core: ['first-open', 'rapid-switch-zero-delay', 'first-scroll', 'resize-window-width'], - scroll: ['first-scroll', 'scroll-down'], + core: ['first-open', 'rapid-switch-zero-delay', 'first-scroll', 'turn-navigation', 'resize-window-width'], + scroll: ['first-scroll', 'scroll-down', 'turn-navigation'], resize: ['resize-window', 'resize-window-width'], full: [ 'first-open', @@ -84,6 +92,7 @@ const profiles = { 'rapid-switch-zero-delay', 'first-scroll', 'scroll-down', + 'turn-navigation', 'resize-window', 'resize-window-width', 'input-layout', diff --git a/tests/e2e/specs/l1-chat-turn-navigation-release.spec.ts b/tests/e2e/specs/l1-chat-turn-navigation-release.spec.ts index 40bdd4a06..267747655 100644 --- a/tests/e2e/specs/l1-chat-turn-navigation-release.spec.ts +++ b/tests/e2e/specs/l1-chat-turn-navigation-release.spec.ts @@ -442,6 +442,15 @@ describe('Release long-session turn navigation', () => { const revealState = await revealHistoryUntilTurnListContains(targetTitle); const clickResult = await clickHeaderTurnListItemByTitle(targetTitle); expect(clickResult.itemCount).toBeGreaterThan(0); + await browser.waitUntil(async () => { + return browser.execute(() => + document.querySelectorAll('.flowchat-header__turn-list-item').length === 0, + ); + }, { + timeout: 500, + interval: 50, + timeoutMsg: '[ReleaseTurnNav] turn list did not close promptly after accepted selection', + }); let lastMetrics = await readTurnViewportMetrics(targetTurnId); await browser.waitUntil(async () => {