From 29bb917f89aa46e987d580fa645c207f5bea6043 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 16:57:25 +0800 Subject: [PATCH 1/2] fix(chat): release superseded history restoration after rollback --- .../modern/FLOWCHAT_HISTORY_PAGING.md | 19 ++ .../modern/FLOWCHAT_VERIFICATION.md | 9 + ...FlowChatContainer.history-restore.test.tsx | 311 ++++++++++++++++++ .../modern/ModernFlowChatContainer.tsx | 63 ++-- 4 files changed, 375 insertions(+), 27 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md index 09c5468e31..903534fcf3 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md @@ -76,6 +76,25 @@ read never navigates to the tail as a fallback. Session, surface and navigation generation fence the result. Failures expose retry, while stale reads may warm the cache without replacing a newer reader intent. +### History rewrites during reader restoration + +Rollback can invalidate a saved range while its Session remains selected. Once +authoritative history is ready, a bookmark extending past the surviving Turn +count is retired together with its viewport snapshot and navigation intent. +The view then presents the surviving tail, or the empty Session. This also +applies when a Peer rewrite leaves `historyState` at `ready`, and when an +explicit host transfer supplies an already-invalid bookmark. A valid bookmark +still restores normally; transport failure retains it for explicit retry. + +Every change of presentation owner clears the previous reader restoration +status when it advances the generation. Otherwise hydration can start a cold +bookmark read in a layout effect, while range invalidation supersedes it in a +passive effect: the old reply is correctly rejected, but its `loading` state +has no remaining owner and hides the transcript indefinitely. Navigation that +supersedes a cold restore has the same obligation. Neither cancellation nor +late success/failure may overwrite the new presentation. Viewport settlement +continues to gate the actual reveal; no timer declares unfinished content ready. + ## Complete Turns are the history publication unit A history transport packet is not a render transaction. A negotiated single-Turn diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index ff642d432b..22b0e3fd8f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -63,6 +63,7 @@ each missing what the other had. | `FlowChatOpeningBoundary.test.tsx` | opening-only activation/scroll isolation, bidirectional focus skipping, programmatic focus return, and reveal cleanup; DOM contracts only | | `HistorySessionLoadingBoundary.test.tsx` | shared 200 ms indicator delay, immediate settled cold/warm opens, off-screen user-message independence, settled-viewport gating, atomic text/tools/code presentation, interaction blocking, session/device cancellation, failure/inactive/resume/reduced-motion handling and no streaming/remount replay; DOM/animation fixtures, not native paint or live remote evidence | | `ModernFlowChatContainer.history-state.test.tsx` | history presentation and the submission event | +| `ModernFlowChatContainer.history-restore.test.tsx` | production container/loading boundary with controlled history IO: rollback with/without an intermediate hydration commit, empty history, retired legacy snapshots/bookmarks, cold host transfer, pending-read cancellation, navigation takeover, late success/failure, retry and Session switching on local/simulated Peer surfaces; no native layout or live transport claim | | `flowChatViewportOwnership.test.ts` | the priority order, preemption, expiry | | `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary | | `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer, fractional normal-flow sizes and offscreen size reservations across reflow | @@ -568,6 +569,14 @@ checks; mobile Remote Control uses its own message renderer. ### History paging +For rollback, start with an older history range visible, roll back one Turn, +then roll back to the first Turn. The view must reveal the surviving tail or +empty Session, and switching away/back must not revive the removed bookmark. +Repeat while a cold bookmark read is pending, with a second controller +initiating the rewrite, and after a failed history read followed by Retry. +These native and live Peer checks remain manual; the container fixture controls +IO timing. + 1. On local and peer devices, open a long session whose last Turn is short. Only that Turn starts at the top, with blank space below; background history must not fill the blank. Repeat with a long last Turn: its end is visible. diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx new file mode 100644 index 0000000000..baec4e4306 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx @@ -0,0 +1,311 @@ +// @vitest-environment jsdom +import React, { act, type ComponentProps } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session, SessionHistoryPresentation } from '../../types/flow-chat'; +import type { VirtualMessageList } from './VirtualMessageList'; + +const fixture = vi.hoisted(() => ({ + session: null as Session | null, + listProps: null as ComponentProps | null, + viewportSettled: true, + cached: null as SessionHistoryPresentation | null, + restore: vi.fn(), + restoreTail: vi.fn(), + present: null as ((sessionId: string, presentation: SessionHistoryPresentation) => void) | null, +})); +vi.mock('../../store/FlowChatStore', () => ({ flowChatStore: { + getState: () => ({ activeSessionId: fixture.session?.sessionId, sessions: new Map(fixture.session ? [[fixture.session.sessionId, fixture.session]] : []) }), + retainSessionHistory: () => () => {}, + subscribeSelector: () => () => {}, + getSessionHistoryViewState: () => undefined, + reactivateSessionHistoryWindow: () => fixture.cached, + restoreSessionHistoryWindow: (...args: unknown[]) => fixture.restore(...args), + restoreSessionTailPresentation: (...args: unknown[]) => fixture.restoreTail(...args), + hasPendingSessionHistoryCompletion: () => false, + hasDeferredSessionHistoryProjection: () => false, + releaseSessionHistoryCompletionAfterInitialPaint: () => false, + extendSessionHistoryWindow: () => null, +} })); +vi.mock('../../store/modernFlowChatStore', async importOriginal => { + const original = await importOriginal(); + return { ...original, + useActiveSession: () => fixture.session, + useVirtualItems: () => original.sessionToVirtualItems(fixture.session!), + useVisibleTurnInfo: () => null, + }; +}); +vi.mock('./VirtualMessageList', () => ({ VirtualMessageList: React.forwardRef((props: ComponentProps, ref) => { + fixture.listProps = props; + React.useImperativeHandle(ref, () => ({ + scrollToTurnEnd: () => true, + captureViewportSnapshot: () => null, + clearSearchMatch: () => {}, + })); + return
+ {props.items.map((item, index) => {item.turnId})} +
; +}) })); +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ FlowChatManager: {} })); +vi.mock('./FlowChatHeader', () => ({ FlowChatHeader: () => null })); +vi.mock('./FlowChatTurnRail', () => ({ FlowChatTurnRail: () => null })); +vi.mock('../background-command/BackgroundCommandInputDialog', () => ({ BackgroundCommandInputDialog: () => null })); +vi.mock('../WelcomePanel', () => ({ WelcomePanel: () =>
})); +vi.mock('./HistorySessionPlaceholder', () => ({ HistorySessionPlaceholder: ({ onRetry }: { onRetry: () => void }) => })); +vi.mock('./useFlowChatSync', () => ({ useFlowChatSync: () => {} })); +vi.mock('./useFlowChatNavigation', () => ({ useFlowChatNavigation: () => {} })); +vi.mock('./useFlowChatCopyDialog', () => ({ useFlowChatCopyDialog: () => {} })); +vi.mock('./useFlowChatToolActions', () => ({ useFlowChatToolActions: () => ({}) })); +vi.mock('./useFlowChatFileActions', () => ({ useFlowChatFileActions: () => ({}) })); +vi.mock('./useFlowChatHttpLinkClick', () => ({ useFlowChatHttpLinkClick: () => undefined })); +vi.mock('./usePermissionRequests', () => ({ usePermissionRequests: () => ({ requests: [] }) })); +vi.mock('../../trajectory/TrajectoryExplorer', () => ({ TrajectoryExplorer: () => null })); +vi.mock('../../trajectory/trajectoryPanel', () => ({ TRAJECTORY_NAVIGATE_EVENT: 'test:trajectory', showTrajectoryRange: () => {} })); +vi.mock('../../trajectory/useTrajectoryNavigation', () => ({ useTrajectoryNavigation: ({ present }: { present: typeof fixture.present }) => { + fixture.present = present; + return () => false; +} })); +vi.mock('../../selection/FlowChatSelectionBar', () => ({ FlowChatSelectionBar: () => null })); +vi.mock('../../selection/ConversationExcerptSources', () => ({ ConversationExcerptSourceProvider: ({ children }: React.PropsWithChildren) => children })); +vi.mock('../../services/btwSessionPane', () => ({ openBtwSessionInAuxPane: () => {} })); +vi.mock('../../services/deleteSessionTreeBranch', () => ({ deleteSessionTreeBranch: () => {} })); +vi.mock('@/infrastructure/hooks/useShortcut', () => ({ useShortcut: () => {} })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useWorkspaceContext: () => ({}) })); +vi.mock('@/app/utils/projectSessionWorkspace', () => ({ flowChatSessionConfigForCurrentWorkspace: () => ({}) })); +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { + listBackgroundCommandActivities: async () => ({ activities: [] }), +} })); + +import { ModernFlowChatContainer } from './ModernFlowChatContainer'; +import { registerConversationReader, stageConversationViewTransfer } from './flowChatViewHandoff'; +import { activateSurface, getActiveSurfaceScope, LOCAL_SURFACE_ID } from '@/infrastructure/peer-device/deviceSurface'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function session(totalTurnCount = 8, historyState: Session['historyState'] = 'ready'): Session { + return { + sessionId: 'session-a', title: 'Conversation', mode: 'Standard', config: {}, + historyState, contextRestoreState: 'ready', totalTurnCount, isPartial: true, + dialogTurns: totalTurnCount === 0 ? [] : [{ + id: `turn-${totalTurnCount - 1}`, status: 'completed', modelRounds: [], + userMessage: { id: `user-${totalTurnCount - 1}`, content: 'Question', timestamp: 1 }, + }], + } as Session; +} +function presentation(startOrdinal = 4, endOrdinalExclusive = 8): SessionHistoryPresentation { + return { + range: { startOrdinal, endOrdinalExclusive, targetTurnId: `turn-${startOrdinal}`, mode: 'history-window' }, + turns: Array.from({ length: endOrdinalExclusive - startOrdinal }, (_, i) => ({ + id: `turn-${startOrdinal + i}`, status: 'completed', modelRounds: [], + userMessage: { id: `user-${startOrdinal + i}`, content: 'Question', timestamp: 1 }, + })), + }; +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +describe('FlowChat reader restoration across history rewrites', () => { + let host: HTMLDivElement; + let root: Root; + const render = async () => { await act(async () => root.render()); }; + const messages = () => host.querySelector('[data-testid="flowchat-messages"]')!; + const expectPresented = () => { + expect(messages().dataset.showHistoryLoadingLayer).toBe('false'); + expect(messages().dataset.historyInitialContentReady).toBe('true'); + expect(host.querySelector('.history-session-loading__overlay')).toBeNull(); + }; + function stageBookmark(window = presentation(), withSnapshot = false) { + const ref = { surfaceId: getActiveSurfaceScope().surfaceId, sessionId: 'session-a' }; + const unregister = registerConversationReader(ref, 'dock', () => ({ + // Also exercise the legacy snapshot shape without an exact item key. + snapshot: withSnapshot ? { + sessionId: ref.sessionId, presentationMode: 'history-window', viewportMode: 'history-reading', + historyWindow: window.range, anchorTurnId: window.range.targetTurnId, + anchorOffsetPx: 24, scrollTopPx: 400, isAtTail: false, capturedAtMs: 1, + } : null, + historyPresentation: { sessionId: ref.sessionId, range: window.range, revision: 1 }, + viewportIntent: { kind: 'turn', sessionId: ref.sessionId, ordinal: window.range.startOrdinal, + turnId: window.range.targetTurnId, source: 'history-range' }, + })); + stageConversationViewTransfer(ref, 'dock'); + unregister(); + } + beforeEach(() => { + vi.clearAllMocks(); + activateSurface(LOCAL_SURFACE_ID); + fixture.session = session(); + fixture.cached = presentation(); + fixture.restore.mockResolvedValue(null); + fixture.listProps = null; + fixture.viewportSettled = true; + fixture.present = null; + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener() {}, removeEventListener() {} })); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + activateSurface(LOCAL_SURFACE_ID); + }); + + it.each([ + ['local', true, 7], ['local', false, 7], ['local', true, 0], ['local', false, 0], + ['peer-device', false, 7], ['peer-device', false, 0], + ] as const)('presents rollback on %s (hydrating commit: %s, surviving Turns: %d)', async (surface, hydrating, remaining) => { + activateSurface(surface); + stageBookmark(presentation(), true); + await render(); + expectPresented(); + if (hydrating) { + fixture.session = session(8, 'hydrating'); + await render(); + } + fixture.cached = null; + const read = deferred(); + fixture.restore.mockReturnValue(read.promise); + fixture.session = session(remaining); + await render(); + await act(async () => read.resolve(null)); + expectPresented(); + expect(messages().dataset.presentationMode).toBe('tail'); + if (remaining) expect(fixture.listProps?.initialViewportSnapshot).toBeNull(); + expect(host.querySelector(remaining ? '[data-turn-id="turn-6"]' : '[data-testid="empty-session"]')).not.toBeNull(); + expect(fixture.restore).not.toHaveBeenCalled(); + + // A second refresh must not revive the retired bookmark or reading anchor. + fixture.session = session(remaining, 'hydrating'); + await render(); + fixture.session = session(remaining); + await render(); + expectPresented(); + expect(fixture.restore).not.toHaveBeenCalled(); + }); + + it('drops an invalid bookmark before a cold host transfer tries to restore it', async () => { + fixture.session = session(3); + fixture.cached = null; + stageBookmark(); + await render(); + expectPresented(); + expect(fixture.restore).not.toHaveBeenCalled(); + expect(messages().dataset.presentationMode).toBe('tail'); + }); + + it('restores a still-valid reader range instead of discarding it on every refresh', async () => { + const window = presentation(2, 4); + fixture.cached = null; + stageBookmark(window); + const read = deferred(); + fixture.restore.mockReturnValue(read.promise); + await render(); + expect(messages().dataset.showHistoryLoadingLayer).toBe('true'); + await act(async () => read.resolve(window)); + expectPresented(); + expect(messages().dataset.presentationMode).toBe('history-window'); + expect(host.querySelector('[data-turn-id="turn-2"]')).not.toBeNull(); + }); + + it('cancels a pending cold range when a ready history rewrite removes it', async () => { + fixture.cached = null; + stageBookmark(); + const read = deferred(); + fixture.restore.mockReturnValue(read.promise); + await render(); + expect(messages().dataset.showHistoryLoadingLayer).toBe('true'); + fixture.session = session(4); + await render(); + expectPresented(); + await act(async () => read.resolve(presentation())); + expectPresented(); + expect(messages().dataset.presentationMode).toBe('tail'); + expect(host.querySelector('[data-turn-id="turn-7"]')).toBeNull(); + }); + + it.each(['resolve', 'reject'] as const)('ends a superseded restore immediately and ignores its late %s', async outcome => { + fixture.cached = null; + stageBookmark(); + const read = deferred(); + fixture.restore.mockReturnValue(read.promise); + await render(); + expect(messages().dataset.showHistoryLoadingLayer).toBe('true'); + await act(async () => fixture.present!('session-a', presentation(1, 3))); + expectPresented(); + await act(async () => { + if (outcome === 'resolve') read.resolve(presentation()); + else read.reject(new Error('Old reader failed')); + }); + expectPresented(); + expect(host.querySelector('[data-turn-id="turn-1"]')).not.toBeNull(); + expect(host.querySelector('[data-turn-id="turn-7"]')).toBeNull(); + }); + + it('still waits for the surviving viewport to settle after retiring the cold restore', async () => { + fixture.cached = null; + stageBookmark(); + const read = deferred(); + fixture.restore.mockReturnValue(read.promise); + await render(); + fixture.viewportSettled = false; + fixture.session = session(7); + await render(); + expect(messages().dataset.showHistoryLoadingLayer).toBe('false'); + expect(messages().dataset.historyInitialContentReady).toBe('false'); + expect(host.querySelector('.history-session-loading__overlay')).not.toBeNull(); + fixture.viewportSettled = true; + await render(); + expectPresented(); + await act(async () => read.resolve(null)); + expectPresented(); + }); + + it('exposes a current restore failure and retries the retained bookmark', async () => { + const window = presentation(2, 4); + fixture.cached = null; + stageBookmark(window); + fixture.restore.mockRejectedValueOnce(new Error('History transport failed')); + await render(); + expectPresented(); + const retry = host.querySelector('button')!; + expect(retry.textContent).toBe('Retry history'); + fixture.restore.mockResolvedValueOnce(window); + await act(async () => retry.click()); + expectPresented(); + expect(fixture.restore).toHaveBeenCalledTimes(2); + expect(host.querySelector('[data-turn-id="turn-2"]')).not.toBeNull(); + }); + + it('does not let a late restore hide another selected Session', async () => { + fixture.cached = null; + stageBookmark(); + const read = deferred(); + fixture.restore.mockReturnValue(read.promise); + await render(); + fixture.session = { ...session(2), sessionId: 'session-b' }; + await render(); + expectPresented(); + await act(async () => read.reject(new Error('Old session disconnected'))); + expectPresented(); + expect(messages().dataset.activeSessionId).toBe('session-b'); + }); + + it('keeps a plain tail readable across overlapping reload notifications', async () => { + await render(); + fixture.session = session(8, 'hydrating'); + await render(); + fixture.session = session(7); + await render(); + fixture.session = session(7, 'hydrating'); + await render(); + fixture.session = session(7); + await render(); + expectPresented(); + expect(fixture.restore).not.toHaveBeenCalled(); + }); +}); 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 0913a3de77..6baddf4ee1 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -352,6 +352,12 @@ export const ModernFlowChatContainer: React.FC = ( after: null, }); const historyPresentationOwnerGenerationRef = useRef(0); + const invalidateHistoryPresentationOwner = useCallback(() => { + // A superseded restore can no longer clear its own status. Transfer the + // loading state together with ownership, before discarding its reply. + historyPresentationOwnerGenerationRef.current += 1; + setHistoryRestore(null); + }, []); const activeHistoryPresentation = historyPresentation?.sessionId === activeSession?.sessionId ? historyPresentation : null; @@ -563,6 +569,15 @@ export const ModernFlowChatContainer: React.FC = ( }, [incomingViewTransfer, activeSession?.sessionId, surfaceScope.surfaceId, viewScope]); const viewportRestorePendingSessionIdRef = useRef(null); const [viewportRestorePendingSessionId, setViewportRestorePendingSessionId] = useState(null); + const rememberedHistoryRange = activeSession?.sessionId + ? incomingViewTransfer?.state.historyPresentation?.range + ?? sessionViewportStateRef.current.get(activeSession.sessionId)?.historyPresentation?.range + : undefined; + const isRememberedHistoryRangeInvalid = Boolean( + activeSession?.historyState === 'ready' + && rememberedHistoryRange + && rememberedHistoryRange.endOrdinalExclusive > activeSessionKnownTurnCount + ); const activeSessionViewportSnapshot = activeSession?.sessionId ? incomingViewTransfer?.state.snapshot ?? sessionViewportStateRef.current.get(activeSession.sessionId)?.snapshot ?? null : null; @@ -711,8 +726,7 @@ export const ModernFlowChatContainer: React.FC = ( sessionId: string, options?: { discardRecentHistory?: boolean }, ) => { - historyPresentationOwnerGenerationRef.current += 1; - setHistoryRestore(null); + invalidateHistoryPresentationOwner(); const retainContinuousProjection = ( options?.discardRecentHistory !== true && activeSession?.sessionId === sessionId @@ -728,13 +742,20 @@ export const ModernFlowChatContainer: React.FC = ( historyPresentationRef.current = null; setHistoryPresentation(null); } - rememberSessionViewportState(sessionId, { snapshot: null }); + viewportRestorePendingSessionIdRef.current = null; + setViewportRestorePendingSessionId(null); + rememberSessionViewportState(sessionId, { + snapshot: null, + ...(options?.discardRecentHistory ? { historyPresentation: null } : {}), + viewportIntent: { kind: 'live-tail', sessionId }, + }); updateViewportIntent({ kind: 'live-tail', sessionId }); setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); setQueuedTurnNavigation(null); }, [ activeSession?.sessionId, continuousHistoryProjectionEligible, + invalidateHistoryPresentationOwner, rememberSessionViewportState, updateViewportIntent, ]); @@ -767,10 +788,15 @@ export const ModernFlowChatContainer: React.FC = ( }); } activeSessionIdRef.current = sessionId ?? null; + // A history rewrite can remove the saved range while this Session stays + // selected. Never start restoring that retired bookmark (or resurrect it + // on a later hydration/session switch). + if (sessionId && isRememberedHistoryRangeInvalid) { + sessionViewportStateRef.current.delete(sessionId); + } const remembered = sessionId ? sessionViewportStateRef.current.get(sessionId) : undefined; - historyPresentationOwnerGenerationRef.current += 1; + invalidateHistoryPresentationOwner(); const bookmark = remembered?.historyPresentation; - setHistoryRestore(null); const cached = sessionId && bookmark ? flowChatStore.reactivateSessionHistoryWindow(sessionId, bookmark.range) : null; @@ -823,7 +849,7 @@ export const ModernFlowChatContainer: React.FC = ( }); return () => { cancelled = true; }; } - }, [activeSession?.sessionId, activeSession?.historyState, rememberSessionViewportState, updateViewportIntent, restoreRevision, historyRestoreRetry, surfaceScope]); + }, [activeSession?.sessionId, activeSession?.historyState, isRememberedHistoryRangeInvalid, invalidateHistoryPresentationOwner, rememberSessionViewportState, updateViewportIntent, restoreRevision, historyRestoreRetry, surfaceScope]); useEffect(() => { const retainedSessionId = continuousProjectionSessionId; @@ -853,28 +879,11 @@ export const ModernFlowChatContainer: React.FC = ( if (!activeHistoryPresentation || activeHistoryPresentationFitsSession) { return; } - historyPresentationOwnerGenerationRef.current += 1; - historyPresentationRef.current = null; - setHistoryPresentation(null); - setContinuousProjectionSessionId(null); - if (activeSession?.sessionId) { - flowChatStore.restoreSessionTailPresentation(activeSession.sessionId); - } - if ( - activeViewportIntent?.kind === 'turn' - && activeViewportIntent.source === 'history-range' - ) { - updateViewportIntent(activeSession?.sessionId - ? { kind: 'live-tail', sessionId: activeSession.sessionId } - : null); - } - setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); + switchToLiveTailForSession(activeHistoryPresentation.sessionId, { discardRecentHistory: true }); }, [ activeHistoryPresentation, activeHistoryPresentationFitsSession, - activeSession?.sessionId, - activeViewportIntent, - updateViewportIntent, + switchToLiveTailForSession, ]); useEffect(() => { @@ -1561,7 +1570,7 @@ export const ModernFlowChatContainer: React.FC = ( }; }, ) => { - historyPresentationOwnerGenerationRef.current += 1; + invalidateHistoryPresentationOwner(); setHistoryPresentation(previous => { const next: FlowChatHistoryPresentationState = { ...presentation, @@ -1589,7 +1598,7 @@ export const ModernFlowChatContainer: React.FC = ( } else { setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); } - }, [updateViewportIntent]); + }, [invalidateHistoryPresentationOwner, updateViewportIntent]); const restoreTailPresentation = useCallback((options?: { followLatest?: boolean; From 71329c603f4df37d299d7c47550a6a65d1c83f78 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 17:08:57 +0800 Subject: [PATCH 2/2] fix(chat): present intentionally empty history projections --- .../modern/FLOWCHAT_HISTORY_PAGING.md | 7 ++++ .../modern/FLOWCHAT_VERIFICATION.md | 4 +-- ...FlowChatContainer.history-restore.test.tsx | 33 +++++++++++++++++-- .../modern/ModernFlowChatContainer.tsx | 20 +++++------ ...rtualMessageList.timeline-prepend.test.tsx | 12 +++++++ .../components/modern/VirtualMessageList.tsx | 2 +- src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 9 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md index 903534fcf3..ae9f8fe02d 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md @@ -95,6 +95,13 @@ supersedes a cold restore has the same obligation. Neither cancellation nor late success/failure may overwrite the new presentation. Viewport settlement continues to gate the actual reveal; no timer declares unfinished content ready. +The modern store publishes the Session and its projected rows atomically. +`ready` with zero rows is therefore not proof of unfinished hydration: an +internal Turn can intentionally omit its input and have no model output. Keep +the transcript controller, header and Turn Rail available when the Session has +known history, so earlier Turns remain reachable; only a truly empty Session +uses the welcome view. Loading follows the explicit hydration/restoration state. + ## Complete Turns are the history publication unit A history transport packet is not a render transaction. A negotiated single-Turn diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 22b0e3fd8f..e75c6fb4b6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -57,13 +57,13 @@ each missing what the other had. | `VirtualMessageList.session-boundary.test.tsx` | prepend compensation, consecutive paging with/without queued input, passive scroll suppression, navigation-target current Turn with gesture/follow/session handoff, search placement only outside the readable viewport, and real viewport-register acknowledgement before trajectory details/highlights commit for user/search/model/folded-tool/history targets | | `VirtualMessageList.resize.test.tsx` | first-frame node and screen-position retention during repeated CSS-only width previews, release, fractional row geometry, native clamping, both row/container observer orders and delayed scroll delivery; completed follow and reader-owned positions on local/simulated peer surfaces, not native or live-transport evidence | | `VirtualMessageList.history-prepend.test.tsx` | full list with real virtualization/follow/ownership: stable row identity and screen offset through short/long completed answers, consecutive/dense prepends, delayed measurement/native scroll and reader-chosen positions on local/simulated peer surfaces; supplied row/DOM geometry, not native or live-transport evidence | -| `VirtualMessageList.timeline-prepend.test.tsx`, `FlowChatPrependSnapshot.test.tsx` | production timeline whole/compound/single-Turn pages; native bottom-origin continuity before observer delivery and zero passive scroll writes, stable hydration lifetime, fractional measurements, bounded long histories, chronological navigation, legacy whole-text and section bookmark coordinates, live handoff and reader-selected block retention; supplied DOM geometry on local/simulated Peer, not native paint or live transport evidence | +| `VirtualMessageList.timeline-prepend.test.tsx`, `FlowChatPrependSnapshot.test.tsx` | production timeline whole/compound/single-Turn pages; native bottom-origin continuity before observer delivery and zero passive scroll writes, stable hydration lifetime, fractional measurements, bounded long histories, empty-projection navigation, chronological navigation, legacy whole-text and section bookmark coordinates, live handoff and reader-selected block retention; supplied DOM geometry on local/simulated Peer, not native paint or live transport evidence | | `flowChatScrollbarMask.test.ts`, `VirtualMessageList.session-boundary.test.tsx` | idle gutter masking with symmetric, one-sided and overlay scrollbars; overflow restoration and content/viewport resizing without scroll writes; supplied DOM geometry, not native rendering or live remote evidence | | `VirtualMessageList.edgeFade.test.ts`, `VirtualMessageList.session-boundary.test.tsx` | repeated scroll-away/back restores the opaque top edge even when an earlier native listener synchronously commits a virtual window; fractional/top-overscroll offsets, one follow notification per event, current gesture callbacks and old-session cleanup; DOM/CSS contracts, not native repaint or live remote evidence | | `FlowChatOpeningBoundary.test.tsx` | opening-only activation/scroll isolation, bidirectional focus skipping, programmatic focus return, and reveal cleanup; DOM contracts only | | `HistorySessionLoadingBoundary.test.tsx` | shared 200 ms indicator delay, immediate settled cold/warm opens, off-screen user-message independence, settled-viewport gating, atomic text/tools/code presentation, interaction blocking, session/device cancellation, failure/inactive/resume/reduced-motion handling and no streaming/remount replay; DOM/animation fixtures, not native paint or live remote evidence | | `ModernFlowChatContainer.history-state.test.tsx` | history presentation and the submission event | -| `ModernFlowChatContainer.history-restore.test.tsx` | production container/loading boundary with controlled history IO: rollback with/without an intermediate hydration commit, empty history, retired legacy snapshots/bookmarks, cold host transfer, pending-read cancellation, navigation takeover, late success/failure, retry and Session switching on local/simulated Peer surfaces; no native layout or live transport claim | +| `ModernFlowChatContainer.history-restore.test.tsx` | production container/loading boundary with controlled history IO: rollback with/without an intermediate hydration commit, empty history, retired legacy snapshots/bookmarks, cold host transfer, pending-read cancellation, navigation takeover, late success/failure, retry, deliberately empty internal projections with accessible earlier history, and Session switching on local/simulated Peer surfaces; no native layout or live transport claim | | `flowChatViewportOwnership.test.ts` | the priority order, preemption, expiry | | `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary | | `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer, fractional normal-flow sizes and offscreen size reservations across reflow | diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx index baec4e4306..d8ab2a4e35 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-restore.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Session, SessionHistoryPresentation } from '../../types/flow-chat'; import type { VirtualMessageList } from './VirtualMessageList'; +import type { FlowChatTurnRail } from './FlowChatTurnRail'; const fixture = vi.hoisted(() => ({ session: null as Session | null, @@ -12,6 +13,7 @@ const fixture = vi.hoisted(() => ({ cached: null as SessionHistoryPresentation | null, restore: vi.fn(), restoreTail: vi.fn(), + loadTurnWindow: vi.fn(), present: null as ((sessionId: string, presentation: SessionHistoryPresentation) => void) | null, })); vi.mock('../../store/FlowChatStore', () => ({ flowChatStore: { @@ -26,6 +28,8 @@ vi.mock('../../store/FlowChatStore', () => ({ flowChatStore: { hasDeferredSessionHistoryProjection: () => false, releaseSessionHistoryCompletionAfterInitialPaint: () => false, extendSessionHistoryWindow: () => null, + loadSessionTurnWindow: (...args: unknown[]) => fixture.loadTurnWindow(...args), + activateSessionHistoryWindow: () => fixture.cached, } })); vi.mock('../../store/modernFlowChatStore', async importOriginal => { const original = await importOriginal(); @@ -41,14 +45,19 @@ vi.mock('./VirtualMessageList', () => ({ VirtualMessageList: React.forwardRef((p scrollToTurnEnd: () => true, captureViewportSnapshot: () => null, clearSearchMatch: () => {}, + prepareTurnNavigation: () => 'pending', })); return
{props.items.map((item, index) => {item.turnId})}
; }) })); vi.mock('@/flow_chat/services/FlowChatManager', () => ({ FlowChatManager: {} })); -vi.mock('./FlowChatHeader', () => ({ FlowChatHeader: () => null })); -vi.mock('./FlowChatTurnRail', () => ({ FlowChatTurnRail: () => null })); +vi.mock('./FlowChatHeader', () => ({ FlowChatHeader: ({ visible }: { visible: boolean }) => visible ?
: null })); +vi.mock('./FlowChatTurnRail', () => ({ FlowChatTurnRail: ({ turns, onNavigate }: ComponentProps) => ( + +) })); vi.mock('../background-command/BackgroundCommandInputDialog', () => ({ BackgroundCommandInputDialog: () => null })); vi.mock('../WelcomePanel', () => ({ WelcomePanel: () =>
})); vi.mock('./HistorySessionPlaceholder', () => ({ HistorySessionPlaceholder: ({ onRetry }: { onRetry: () => void }) => })); @@ -308,4 +317,24 @@ describe('FlowChat reader restoration across history rewrites', () => { expectPresented(); expect(fixture.restore).not.toHaveBeenCalled(); }); + + it('keeps earlier history reachable when a ready internal tail deliberately projects no rows', async () => { + fixture.session = session(8); + const turn = fixture.session.dialogTurns[0]; + turn.userMessage = { ...turn.userMessage, metadata: { genUIHostRepair: true } }; + await render(); + expect(messages().dataset.virtualItemCount).toBe('0'); + expectPresented(); + expect(host.querySelector('[data-testid="empty-session"]')).toBeNull(); + expect(host.querySelector('[data-testid="history-header"]')).not.toBeNull(); + expect(host.querySelector('.virtual-message-list')).not.toBeNull(); + fixture.cached = presentation(0, 2); + fixture.loadTurnWindow.mockResolvedValueOnce({ + status: 'ready', isCurrent: true, targetOrdinal: 0, targetTurnId: 'turn-0', navigationGeneration: 1, + }); + await act(async () => host.querySelector('[data-rail-ordinal="0"]')!.click()); + expectPresented(); + expect(fixture.loadTurnWindow).toHaveBeenCalledWith('session-a', 0, { source: 'target' }); + expect(host.querySelector('[data-turn-id="turn-0"]')).not.toBeNull(); + }); }); 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 6baddf4ee1..a28d6aa8fe 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -605,10 +605,6 @@ export const ModernFlowChatContainer: React.FC = ( const activeWorkspace = viewScope ? undefined : workspaceContext.activeWorkspace; const allowUserMessageRollback = !isAcpFlowSession(activeSession); const historyState = activeSession?.historyState; - const hasRestoredTurnsPendingVirtualItems = - historyState === 'ready' && - (activeSession?.dialogTurns.length ?? 0) > 0 && - virtualItems.length === 0; const rememberSessionViewportState = useCallback(( sessionId: string, @@ -672,9 +668,13 @@ export const ModernFlowChatContainer: React.FC = ( const showHistoryPlaceholder = virtualItems.length === 0 && ( historyState === 'metadata-only' || historyState === 'hydrating' || - historyState === 'failed' || - hasRestoredTurnsPendingVirtualItems + historyState === 'failed' ); + // Session and projected rows commit atomically. A ready internal Turn may + // deliberately project no rows; it is not an unfinished hydration. Keep the + // list/navigation mounted so earlier history remains reachable. + const showSessionTranscript = virtualItems.length > 0 + || (historyState === 'ready' && activeSessionKnownTurnCount > 0); const isPendingHistoryOpenActiveSession = pendingHistoryOpenSession !== null && activeSession?.sessionId === pendingHistoryOpenSession.sessionId; @@ -2697,7 +2697,7 @@ export const ModernFlowChatContainer: React.FC = ( virtualListRef.current?.notifyUserSelectionIntent()} /> 0} + visible={showSessionTranscript} sessionId={activeSession?.sessionId} isRightPanelOpen={isRightPanelOpen} onToggleRightPanel={onToggleRightPanel} @@ -2764,7 +2764,7 @@ export const ModernFlowChatContainer: React.FC = ( 0 && activeHistoryRestore?.status !== 'loading' && !showFailedHistoryPlaceholder} + hasTranscript={showSessionTranscript && activeHistoryRestore?.status !== 'loading' && !showFailedHistoryPlaceholder} disabled={showFailedHistoryPlaceholder || !isViewportActive} intent={showHistoryOpenIntentOverlay} label={t('historyState.loadingTitle')} @@ -2775,7 +2775,7 @@ export const ModernFlowChatContainer: React.FC = ( state="failed" onRetry={handleRetryHistoryLoad} /> - ) : activeHistoryRestore?.status === 'loading' ? null : virtualItems.length === 0 ? ( + ) : activeHistoryRestore?.status === 'loading' ? null : !showSessionTranscript ? ( showHistoryPlaceholder || showHistoryOpenIntentOverlay ? null : ( emptyState !== undefined ? emptyState : ( = ( /> )} - {virtualItems.length > 0 ? ( + {showSessionTranscript ? ( { { page: 'older turn plus missing earlier blocks', partial: true, addsTurn: true }, { page: 'missing earlier blocks within the loaded head', partial: true, addsTurn: false }, ]; + it('presents an empty projected tail and accepts navigation into earlier history', () => { + state.items = []; + render(); + for (let index = 0; index < 45; index++) tick(); + expect(host.querySelector('[data-testid="flowchat-message-list-empty"]')).not.toBeNull(); + act(() => expect(listRef.current!.prepareTurnNavigation('previous', { behavior: 'auto' })).toBe('pending')); + state.items = turn('previous', 180); + render(); resize(); + for (let index = 0; index < 45; index++) tick(); + expect(host.querySelector('[data-turn-id="previous"]')).not.toBeNull(); + expect(host.querySelector('.virtual-message-list')!.dataset.openViewportSettled).toBe('true'); + }); it('publishes the latest committed history window without a mutation-phase snapshot on paging', () => { const onSnapshot = vi.fn(); const list = (startOrdinal: number) =>
-

No messages yet

+

{t('historyState.emptyWindow')}

); diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 43311b2eb8..0bf025048c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -190,6 +190,7 @@ "waitingForModelResponse": "Waiting for model response..." }, "historyState": { + "emptyWindow": "No visible messages in this history window.", "loadingTitle": "Loading saved session", "loadingDescription": "Preparing the conversation history.", "failedTitle": "Session history did not load", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 361fca49b7..8e55fb035c 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -190,6 +190,7 @@ "waitingForModelResponse": "等待模型响应..." }, "historyState": { + "emptyWindow": "这段历史中没有可显示的消息。", "loadingTitle": "正在加载历史会话", "loadingDescription": "正在准备该会话的历史内容。", "failedTitle": "历史会话加载失败", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index d4b9319926..0313203b96 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -190,6 +190,7 @@ "waitingForModelResponse": "等待模型回應..." }, "historyState": { + "emptyWindow": "這段歷史中沒有可顯示的訊息。", "loadingTitle": "正在載入歷史會話", "loadingDescription": "正在準備該會話的歷史內容。", "failedTitle": "歷史會話載入失敗",