From aa434904125036aa5d83242f4b4aa6adb2e1c2ec Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 31 Jul 2026 15:39:08 +0800 Subject: [PATCH] fix(web-ui): stabilize FlowChat history paging - Keep the static history scroller mounted while prepending older turns. - Restore a semantic user-message anchor across window expansion and prepend. - Trigger pagination near the loaded-history boundary with a fixed loading sentinel. - Remove the sentinel after the full history has been revealed. - Add focused regression coverage for paging boundaries and static expansion. --- .../components/modern/VirtualMessageList.scss | 29 ++ ...rtualMessageList.session-boundary.test.tsx | 126 ++++++++ .../components/modern/VirtualMessageList.tsx | 288 +++++++++++++++++- 3 files changed, 432 insertions(+), 11 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss index 274093022a..8d32af4452 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -92,6 +92,30 @@ overflow-wrap: anywhere; pointer-events: none; } + + &__history-paging-sentinel { + width: 100%; + height: 28px; + min-height: 28px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--color-text-muted); + font-size: 12px; + line-height: 18px; + pointer-events: none; + overflow-anchor: none; + visibility: hidden; + + &[data-history-paging-sentinel='loading'] { + visibility: visible; + } + } + + &__history-paging-spinner { + animation: history-paging-spin 0.9s linear infinite; + } .message-list-footer { /* Inline height from VirtualMessageList (measured drop-zone + bottom inset + tail clearance). */ @@ -150,3 +174,8 @@ } } + +@keyframes history-paging-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 7c547ab905..17c2c0e3ca 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -197,6 +197,9 @@ vi.mock('../../store/chatInputStateStore', () => ({ vi.mock('../../store/FlowChatStore', () => ({ flowChatStore: { + getState: () => ({ + sessions: new Map(stateMocks.activeSession ? [[stateMocks.activeSession.sessionId, stateMocks.activeSession]] : []), + }), hasPendingSessionHistoryCompletion: flowStoreMocks.hasPendingSessionHistoryCompletion, hasDeferredSessionHistoryProjection: flowStoreMocks.hasDeferredSessionHistoryProjection, requestSessionFullHistoryProjection: flowStoreMocks.requestSessionFullHistoryProjection, @@ -2153,6 +2156,129 @@ describe('VirtualMessageList session boundary', () => { expect(flowStoreMocks.revealPreviousSessionHistoryWindow).toHaveBeenCalledWith('session-a', 'wheel-up'); }); + it('expands partial static history before prepending older turns', () => { + flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); + flowStoreMocks.revealPreviousSessionHistoryWindow.mockReturnValue(true); + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-3', 'turn-4', 'turn-5'], { + isHistorical: true, + historyState: 'ready', + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 6, + }); + stateMocks.virtualItems = ['turn-3', 'turn-4', 'turn-5'].flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), + ]); + + act(() => { + root.render(); + }); + + const staticScroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(staticScroller).not.toBeNull(); + expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); + + act(() => { + staticScroller?.dispatchEvent(new WheelEvent('wheel', { + deltaY: -120, + bubbles: true, + })); + }); + + expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); + expect(container.querySelector('[data-testid="virtuoso"]')).toBeNull(); + expect(container.querySelector('[data-history-initial-render-spacer="true"]')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-3"]')).not.toBeNull(); + expect(container.querySelector('[data-history-paging-sentinel="loading"]')).not.toBeNull(); + expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); + + flushAnimationFrame(); + flushAnimationFrame(); + + expect(flowStoreMocks.revealPreviousSessionHistoryWindow).toHaveBeenCalledWith('session-a', 'wheel-up'); + + stateMocks.activeSession = createSessionWithTurns( + 'session-a', + ['turn-0', 'turn-1', 'turn-2', 'turn-3', 'turn-4', 'turn-5'], + { + isHistorical: true, + historyState: 'ready', + contextRestoreState: 'pending', + isPartial: false, + loadedTurnCount: 6, + totalTurnCount: 6, + }, + ); + stateMocks.virtualItems = ['turn-0', 'turn-1', 'turn-2', 'turn-3', 'turn-4', 'turn-5'].flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), + ]); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-history-initial-render-spacer="true"]')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-3"]')).not.toBeNull(); + expect(container.querySelector('[data-history-paging-sentinel]')).toBeNull(); + }); + + it('waits until the static history boundary is near before starting pagination', () => { + flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-3', 'turn-4', 'turn-5'], { + isHistorical: true, + historyState: 'ready', + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 6, + }); + stateMocks.virtualItems = ['turn-3', 'turn-4', 'turn-5'].flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), + ]); + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const spacer = container.querySelector('[data-history-initial-render-spacer="true"]'); + expect(scroller).not.toBeNull(); + expect(spacer).not.toBeNull(); + if (!scroller || !spacer) { + return; + } + + const spacerHeight = Number.parseFloat(spacer.style.height); + setScrollerGeometry(scroller, { + scrollHeight: spacerHeight + 3_000, + clientHeight: 1_000, + scrollTop: spacerHeight + 500, + }); + + act(() => { + scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + flushAnimationFrame(); + flushAnimationFrame(); + + expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); + expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); + + scroller.scrollTop = spacerHeight + 100; + act(() => { + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); + expect(container.querySelector('[data-history-initial-render-spacer="true"]')).toBeNull(); + expect(container.querySelector('[data-history-paging-sentinel="loading"]')).not.toBeNull(); + }); + it('does not reveal previous history for upward scroll away from the history boundary', () => { flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); stateMocks.activeSession = createSession('session-a', 'turn-a', { 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 d665a542b9..d2b5227260 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -19,6 +19,7 @@ import { type ContextProp, } from 'react-virtuoso'; import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; import { useActiveSessionState } from '../../hooks/useActiveSessionState'; import { VirtualItemRenderer } from './VirtualItemRenderer'; import { ScrollToLatestBar } from '../ScrollToLatestBar'; @@ -114,6 +115,7 @@ const LATEST_END_ANCHOR_STATIC_FAST_PATH_TOLERANCE_PX = 96; const VIRTUOSO_FIRST_ITEM_INDEX_BASE = 1_000_000; const PARTIAL_HISTORY_INITIAL_TAIL_TURN_BUDGET = 16; const PARTIAL_HISTORY_FULL_PROJECTION_TOP_THRESHOLD_PX = 1200; +const PARTIAL_HISTORY_STATIC_BOUNDARY_THRESHOLD_PX = 120; const HISTORY_PROJECTION_HANDOFF_MAX_DURATION_MS = 5000; const SESSION_OPEN_HANDOFF_ITEM_BUDGET = 24; const PREVIOUS_HISTORY_BOUNDARY_STATUS_DURATION_MS = 2500; @@ -138,6 +140,20 @@ const FlowChatVirtuosoHeader = ({ context }: ContextProp ); +const FlowChatHistoryPagingSentinel = ({ visible, label }: { visible: boolean; label: string }) => ( +
+ + {label} +
+); + const FlowChatVirtuosoFooter = ({ context }: ContextProp) => (
(null); const [historyProjectionHandoff, setHistoryProjectionHandoff] = useState(null); const [expandedInitialHistoryRenderKey, setExpandedInitialHistoryRenderKey] = useState(null); + const [historyPagingActive, setHistoryPagingActive] = useState(false); + const [historyPagingLoading, setHistoryPagingLoading] = useState(false); + const [historyPagingAnchorTurnId, setHistoryPagingAnchorTurnId] = useState(null); const [staticAnchorWindowTurnId, setStaticAnchorWindowTurnId] = useState(null); const [previousHistoryBoundaryStatus, setPreviousHistoryBoundaryStatus] = useState<{ sessionId: string; @@ -451,6 +470,20 @@ const VirtualMessageListSession = forwardRef(null); + const pendingHistoryPrependAnchorRef = useRef<{ + turnId: string; + offsetFromScrollerTop: number; + beforeItemCount: number; + handoffRestored: boolean; + prependRestored: boolean; + } | null>(null); + const historyPagingActiveRef = useRef(false); + const historyPagingRevealScheduledRef = useRef(false); + const pendingHistoryPagingRevealRef = useRef<{ + sessionId: string; + reason: string; + } | null>(null); + const historyPagingRetryTimerRef = useRef(null); const pendingStaticTurnPinRef = useRef(null); const pendingStaticLatestScrollBehaviorRef = useRef<('auto' | 'smooth') | null>(null); const initialHistoryRenderWindowCheckFrameRef = useRef(null); @@ -3091,6 +3124,10 @@ const VirtualMessageListSession = forwardRef { @@ -3102,6 +3139,32 @@ const VirtualMessageListSession = forwardRef { + const scroller = scrollerElementRef.current; + if (!scroller) { + return null; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const nodes = Array.from(scroller.querySelectorAll( + '.virtual-item-wrapper[data-item-type="user-message"][data-turn-id]', + )); + const visibleNode = nodes.find(node => { + const rect = node.getBoundingClientRect(); + return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; + }); + const node = visibleNode ?? nodes[0]; + const turnId = node?.dataset.turnId; + if (!node || !turnId) { + return null; + } + + return { + turnId, + offsetFromScrollerTop: node.getBoundingClientRect().top - scrollerRect.top, + }; + }, []); + const revealPreviousHistoryWindowForUserIntent = useCallback((reason: string) => { const sessionId = activeSession?.sessionId; if ( @@ -3112,6 +3175,46 @@ const VirtualMessageListSession = forwardRef { + historyPagingRetryTimerRef.current = null; + if (activeSessionIdRef.current === sessionId && historyPagingActiveRef.current) { + revealPreviousHistoryWindowForUserIntent('history-ready-after-scroll'); + } + }, 50); + } } else { + setHistoryPagingLoading(false); showPreviousHistoryBoundaryStatus(sessionId, reason, 'not-ready'); startupTrace.markPhase('flowchat_previous_history_window_not_ready', { sessionId, @@ -3135,9 +3247,14 @@ const VirtualMessageListSession = forwardRef { + const pendingReveal = pendingHistoryPagingRevealRef.current; + const pendingAnchor = pendingHistoryPrependAnchorRef.current; + if ( + !pendingReveal || + !historyPagingActive || + !scrollerElement?.isConnected || + (pendingAnchor && !pendingAnchor.handoffRestored) || + historyPagingRevealScheduledRef.current + ) { + return; + } + + historyPagingRevealScheduledRef.current = true; + const frameId = requestAnimationFrame(() => { + historyPagingRevealScheduledRef.current = false; + const currentPendingReveal = pendingHistoryPagingRevealRef.current; + if ( + !currentPendingReveal || + currentPendingReveal.sessionId !== pendingReveal.sessionId || + activeSessionIdRef.current !== pendingReveal.sessionId + ) { + return; + } + pendingHistoryPagingRevealRef.current = null; + revealPreviousHistoryWindowForUserIntent(currentPendingReveal.reason); + }); + + return () => { + cancelAnimationFrame(frameId); + historyPagingRevealScheduledRef.current = false; + }; + }, [ + historyPagingActive, + revealPreviousHistoryWindowForUserIntent, + scrollerElement, ]); const shouldRevealPreviousHistoryWindowForUserIntent = useCallback((options?: { force?: boolean }) => { @@ -3230,6 +3387,17 @@ const VirtualMessageListSession = forwardRef round.isStreaming); }, [activeSession, isProcessing]); const initialTopMostItemIndex = React.useMemo(() => { + if (historyPagingActive && historyPagingAnchorTurnId) { + const anchorIndex = virtualItems.findIndex(item => ( + item.turnId === historyPagingAnchorTurnId && item.type === 'user-message' + )); + if (anchorIndex >= 0) { + return { + index: toVirtuosoIndex(anchorIndex), + align: 'start' as const, + }; + } + } + if (isStreamingOutput) { return toVirtuosoIndex(latestUserMessageIndex); } @@ -4505,9 +4685,58 @@ const VirtualMessageListSession = forwardRef { + if (!historyPagingActive || !historyPagingAnchorTurnId) { + return; + } + + const pendingAnchor = pendingHistoryPrependAnchorRef.current; + if (!pendingAnchor || pendingAnchor.turnId !== historyPagingAnchorTurnId) { + return; + } + const isPrependCommit = virtualItems.length > pendingAnchor.beforeItemCount; + if (isPrependCommit ? pendingAnchor.prependRestored : pendingAnchor.handoffRestored) { + return; + } + + const scroller = scrollerElementRef.current; + const anchorElement = getRenderedUserMessageElement(historyPagingAnchorTurnId); + if (!scroller || !anchorElement) { + return; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const actualOffset = anchorElement.getBoundingClientRect().top - scrollerRect.top; + const correction = actualOffset - pendingAnchor.offsetFromScrollerTop; + if (Math.abs(correction) > COMPENSATION_EPSILON_PX) { + scroller.scrollTop = Math.max(0, scroller.scrollTop + correction); + } + if (isPrependCommit) { + pendingAnchor.prependRestored = true; + setHistoryPagingLoading(false); + clearPreviousHistoryBoundaryStatus(); + } else { + pendingAnchor.handoffRestored = true; + } + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); + recordScrollerGeometry(scroller); + }, [ + clearPreviousHistoryBoundaryStatus, + getRenderedUserMessageElement, + historyPagingActive, + historyPagingAnchorTurnId, + recordScrollerGeometry, + scrollerElement, + snapshotMeasuredContentHeight, + virtualItems, ]); useEffect(() => { @@ -5916,6 +6145,7 @@ const VirtualMessageListSession = forwardRef { if ( !useStaticInitialHistoryList || - !initialHistoryRenderWindow.isWindowed || - isInitialHistoryRenderWindowExpanded || - omittedInitialHistoryEstimatedHeightPx <= 0 + isInitialHistoryRenderWindowExpanded && activeSession?.isPartial !== true ) { return; } const scroller = scrollerElementRef.current; - if (!scroller || scroller.scrollTop > omittedInitialHistoryEstimatedHeightPx) { + if (!scroller) { + return; + } + + if (activeSession?.isPartial === true) { + const pagingThresholdPx = omittedInitialHistoryEstimatedHeightPx + PARTIAL_HISTORY_STATIC_BOUNDARY_THRESHOLD_PX; + if (scroller.scrollTop <= pagingThresholdPx) { + revealPreviousHistoryWindowForUserIntent(reason); + } + return; + } + + if (!initialHistoryRenderWindow.isWindowed || omittedInitialHistoryEstimatedHeightPx <= 0) { + return; + } + + if (scroller.scrollTop > omittedInitialHistoryEstimatedHeightPx) { return; } expandInitialHistoryRenderWindow(reason); }, [ expandInitialHistoryRenderWindow, + activeSession?.isPartial, initialHistoryRenderWindow.isWindowed, isInitialHistoryRenderWindowExpanded, omittedInitialHistoryEstimatedHeightPx, + revealPreviousHistoryWindowForUserIntent, useStaticInitialHistoryList, ]); const scheduleInitialHistoryRenderWindowCheck = useCallback((reason: string) => { @@ -6077,9 +6323,13 @@ const VirtualMessageListSession = forwardRef { - scheduleInitialHistoryRenderWindowCheck('wheel-near-omitted-history'); - }, [scheduleInitialHistoryRenderWindowCheck]); + const handleInitialHistoryStaticWheelCapture = useCallback((event: React.WheelEvent) => { + if (event.deltaY >= 0) { + return; + } + expandInitialHistoryRenderWindowIfNeeded('wheel-up'); + scheduleInitialHistoryRenderWindowCheck('wheel-up'); + }, [expandInitialHistoryRenderWindowIfNeeded, scheduleInitialHistoryRenderWindowCheck]); const handleInitialHistoryStaticKeyDownCapture = useCallback((event: React.KeyboardEvent) => { if ( event.key === 'Home' || @@ -6376,7 +6626,9 @@ const VirtualMessageListSession = forwardRef previousHistoryBoundaryStatus?.sessionId === activeSessionId ? ( + () => previousHistoryBoundaryStatus?.sessionId === activeSessionId && ( + previousHistoryBoundaryStatus.state === 'not-ready' || !historyPagingActive + ) ? (
) : null, - [activeSessionId, previousHistoryBoundaryStatus, t], + [activeSessionId, historyPagingActive, previousHistoryBoundaryStatus, t], + ); + const historyPagingSentinelNode = React.useMemo( + () => historyPagingActive && activeSession?.isPartial === true ? ( + + ) : null, + [activeSession?.isPartial, historyPagingActive, historyPagingLoading, t], ); const virtuosoContext = React.useMemo(() => ({ // Reservation pixels are applied imperatively to the stable Footer node. // Keeping them out of context prevents a measurement-sensitive Virtuoso // render for every compensation update. footerRef: handleFooterElementRef, - previousHistoryBoundaryStatusNode, + previousHistoryBoundaryStatusNode: <> + {historyPagingSentinelNode} + {previousHistoryBoundaryStatusNode} + , runtimeStatusSessionId: activeSessionId, }), [ activeSessionId, handleFooterElementRef, + historyPagingSentinelNode, previousHistoryBoundaryStatusNode, ]); const computeVirtuosoItemKey = useCallback((_: number, item: VirtualItem) => ( @@ -6441,6 +6706,7 @@ const VirtualMessageListSession = forwardRef
+ {historyPagingSentinelNode} {previousHistoryBoundaryStatusNode} {omittedInitialHistoryEstimatedHeightPx > 0 ? (