Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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(<FlowChatHeader {...initialProps} currentTurn={2} />);
Expand All @@ -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(<FlowChatHeader {...createProps({ onJumpToTurn })} />);
Expand All @@ -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(<FlowChatHeader {...createProps({ onJumpToTurn })} />);
});

const turnListButton = container.querySelector<HTMLButtonElement>('[data-testid="flowchat-header-turn-list"]');
act(() => {
turnListButton?.click();
});

const turnItems = Array.from(container.querySelectorAll<HTMLButtonElement>('.flowchat-header__turn-list-item'));
act(() => {
turnItems[1]?.click();
});

expect(onJumpToTurn).toHaveBeenCalledWith('turn-2');
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
});
});
12 changes: 4 additions & 8 deletions src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -313,14 +313,10 @@ export const FlowChatHeader: React.FC<FlowChatHeaderProps> = ({

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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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',
});
Expand All @@ -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(<ModernFlowChatContainer />);
Expand All @@ -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',
});
Expand All @@ -939,7 +944,7 @@ describe('ModernFlowChatContainer historical empty state', () => {
totalTurns: 2,
});

virtualListMock.pinTurnToTop.mockReturnValue(true);
virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled');
stateMocks.virtualItems = [
...stateMocks.virtualItems,
];
Expand All @@ -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',
});
Expand All @@ -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',
Expand All @@ -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(<ModernFlowChatContainer />);
});

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',
});
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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(<ModernFlowChatContainer />);
});

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<Session>);
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(<ModernFlowChatContainer />);
});

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<Session>);
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(<ModernFlowChatContainer />);
});

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<Session>);
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(<ModernFlowChatContainer />);
});

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 () => {
Expand All @@ -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 () => {
Expand Down
Loading
Loading