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 () => {