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 new file mode 100644 index 000000000..b819142e6 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowChatHeader, type FlowChatHeaderProps } from './FlowChatHeader'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (key === 'flowChatHeader.turnBadge') { + return `Turn ${values?.current ?? ''}`; + } + return key; + }, + }), +})); + +vi.mock('@/component-library', async () => { + const ReactModule = await import('react'); + + return { + Tooltip: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + IconButton: ({ + children, + size, + tooltip, + variant, + ...props + }: React.ButtonHTMLAttributes & { + size?: string; + tooltip?: string; + variant?: string; + }) => ( + + ), + Input: ReactModule.forwardRef>((props, ref) => ( + + )), + }; +}); + +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useWorkspaceContext: () => ({ + currentWorkspace: { rootPath: '/workspace' }, + }), +})); + +vi.mock('@/shared/utils/tabUtils', () => ({ + createReviewPlatformTab: vi.fn(), +})); + +vi.mock('./SessionFilesBadge', () => ({ + SessionFilesBadge: () =>
, +})); + +function createProps(overrides: Partial = {}): FlowChatHeaderProps { + return { + currentTurn: 1, + totalTurns: 2, + currentUserMessage: 'First prompt', + visible: true, + turns: [ + { turnId: 'turn-1', turnIndex: 1, title: 'First prompt' }, + { turnId: 'turn-2', turnIndex: 2, title: 'Second prompt' }, + ], + onJumpToTurn: vi.fn(), + ...overrides, + }; +} + +describe('FlowChatHeader', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('keeps the turn list open until the selected turn becomes current', () => { + const onJumpToTurn = vi.fn(); + const initialProps = createProps({ onJumpToTurn }); + + act(() => { + root.render(); + }); + + const turnListButton = container.querySelector('[data-testid="flowchat-header-turn-list"]'); + expect(turnListButton).not.toBeNull(); + + act(() => { + turnListButton?.click(); + }); + + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + + const turnItems = Array.from(container.querySelectorAll('.flowchat-header__turn-list-item')); + expect(turnItems).toHaveLength(2); + + act(() => { + turnItems[1]?.click(); + }); + + expect(onJumpToTurn).toHaveBeenCalledWith('turn-2'); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('closes the turn list and notifies the container when selecting the current turn', () => { + const onJumpToTurn = vi.fn(); + + act(() => { + root.render(); + }); + + const turnListButton = container.querySelector('[data-testid="flowchat-header-turn-list"]'); + act(() => { + turnListButton?.click(); + }); + + const currentTurnItem = container.querySelector('.flowchat-header__turn-list-item'); + act(() => { + currentTurnItem?.click(); + }); + + expect(onJumpToTurn).toHaveBeenCalledWith('turn-1'); + expect(container.querySelector('[role="dialog"]')).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 2b419211a..6040b4aa1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -313,8 +313,14 @@ 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); + setIsTurnListOpen(false); + return; + } + onJumpToTurn(turnId); - setIsTurnListOpen(false); }; const handleSubagentSelect = (sessionId: string) => { @@ -908,4 +914,3 @@ export const FlowChatHeader: React.FC = ({ }; FlowChatHeader.displayName = 'FlowChatHeader'; - 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 6e4557845..a6bdcd70f 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 @@ -894,6 +894,68 @@ describe('ModernFlowChatContainer historical empty state', () => { }); }); + it('retries header turn selection without advancing header state until the virtual list accepts it', 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.pinTurnToTop.mockReturnValue(false); + + await act(async () => { + root.render(); + }); + + expect(headerPropsMock.latest).toMatchObject({ + currentTurn: 2, + totalTurns: 2, + }); + + await act(async () => { + (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => void) | undefined)?.('turn-1'); + }); + + expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-1', { + behavior: 'smooth', + pinMode: 'transient', + }); + expect(headerPropsMock.latest).toMatchObject({ + currentTurn: 2, + totalTurns: 2, + }); + + virtualListMock.pinTurnToTop.mockReturnValue(true); + stateMocks.virtualItems = [ + ...stateMocks.virtualItems, + ]; + + await act(async () => { + root.render(); + }); + + expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-1', { + behavior: 'smooth', + pinMode: 'transient', + }); + expect(headerPropsMock.latest).toMatchObject({ + currentTurn: 1, + totalTurns: 2, + }); + }); + it('does not expose previous navigation before the loaded tail range in partial history', async () => { stateMocks.activeSession = createSession({ isHistorical: false, 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 8566d6ec0..aabbfb134 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -220,6 +220,7 @@ export const ModernFlowChatContainer: React.FC = ( const activeSession = useActiveSession(); const visibleTurnInfo = useVisibleTurnInfo(); const [pendingHeaderTurnId, setPendingHeaderTurnId] = useState(null); + const [queuedHeaderTurnPinId, setQueuedHeaderTurnPinId] = useState(null); const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); const [searchOpenRequest, setSearchOpenRequest] = useState(0); // Track whether a slash-command or @-mention popup is open in ChatInput. @@ -663,6 +664,47 @@ export const ModernFlowChatContainer: React.FC = ( } }, [pendingHeaderTurnId, turnSummaries, visibleTurnInfo?.turnId]); + const requestHeaderTurnPin = useCallback((turnId: string) => { + 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, { + behavior: 'smooth', + pinMode, + }) ?? false; + }, [activeSession?.dialogTurns, turnSummaries]); + + useEffect(() => { + if (!queuedHeaderTurnPinId) return; + + if (visibleTurnInfo?.turnId === queuedHeaderTurnPinId) { + setQueuedHeaderTurnPinId(null); + return; + } + + const targetStillExists = turnSummaries.some(turn => turn.turnId === queuedHeaderTurnPinId); + if (!targetStillExists) { + setQueuedHeaderTurnPinId(null); + setPendingHeaderTurnId(null); + return; + } + + const accepted = requestHeaderTurnPin(queuedHeaderTurnPinId); + if (!accepted) return; + + setQueuedHeaderTurnPinId(null); + setPendingHeaderTurnId(queuedHeaderTurnPinId); + }, [ + queuedHeaderTurnPinId, + requestHeaderTurnPin, + turnSummaries, + virtualItems, + visibleTurnInfo?.turnId, + ]); + useLayoutEffect(() => { autoPinnedTurnKeyRef.current = null; releasedHistoryCompletionKeyRef.current = null; @@ -672,6 +714,7 @@ export const ModernFlowChatContainer: React.FC = ( setHistoryInitialContentReadyKey(null); setHistoryInitialContentPostPaintKey(null); setPendingHeaderTurnId(null); + setQueuedHeaderTurnPinId(null); }, [activeSession?.sessionId]); useLayoutEffect(() => { @@ -969,19 +1012,23 @@ export const ModernFlowChatContainer: React.FC = ( const handleJumpToTurn = useCallback((turnId: string) => { if (!turnId) return; - const isLatestTurn = turnSummaries[turnSummaries.length - 1]?.turnId === turnId; - const targetTurn = findDialogTurn(activeSession?.dialogTurns, turnId); - const pinMode = isLatestTurn && shouldUseStickyLatestPin(targetTurn) - ? 'sticky-latest' - : 'transient'; + const targetStillExists = turnSummaries.some(turn => turn.turnId === turnId); + if (!targetStillExists) { + setQueuedHeaderTurnPinId(null); + setPendingHeaderTurnId(null); + return; + } - const accepted = virtualListRef.current?.pinTurnToTop(turnId, { - behavior: 'smooth', - pinMode, - }) ?? false; + const accepted = requestHeaderTurnPin(turnId); + if (accepted) { + setQueuedHeaderTurnPinId(null); + setPendingHeaderTurnId(turnId); + return; + } - setPendingHeaderTurnId(accepted ? turnId : null); - }, [activeSession?.dialogTurns, turnSummaries]); + setQueuedHeaderTurnPinId(turnId); + setPendingHeaderTurnId(null); + }, [requestHeaderTurnPin, turnSummaries]); const handleJumpToPreviousTurn = useCallback(() => { if (!navigationVisibleTurnInfo || navigationVisibleTurnInfo.turnIndex <= 1) return;