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 667228a6e..621dd09e4 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 @@ -45,6 +45,9 @@ const searchStateMock = vi.hoisted(() => ({ const headerPropsMock = vi.hoisted(() => ({ latest: null as Record | null, })); +const agentApiMock = vi.hoisted(() => ({ + listBackgroundCommandActivities: vi.fn(() => Promise.resolve({ activities: [] })), +})); vi.mock('react-i18next', () => ({ initReactI18next: { @@ -93,6 +96,10 @@ vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ }), })); +vi.mock('@/infrastructure/api', () => ({ + agentAPI: agentApiMock, +})); + vi.mock('../../utils/acpSession', () => ({ isAcpFlowSession: () => false, })); @@ -244,6 +251,8 @@ describe('ModernFlowChatContainer historical empty state', () => { virtualListMock.pinTurnToTop.mockReturnValue(true); virtualListActionClickMock.mockReset(); startupTraceMock.markPhase.mockReset(); + agentApiMock.listBackgroundCommandActivities.mockClear(); + agentApiMock.listBackgroundCommandActivities.mockResolvedValue({ activities: [] }); searchStateMock.searchQuery = ''; searchStateMock.onSearchChange.mockReset(); searchStateMock.matches = []; @@ -554,6 +563,46 @@ describe('ModernFlowChatContainer historical empty state', () => { releaseSpy.mockRestore(); }); + it('defers background command snapshot until restored latest text is visible', async () => { + const releaseSpy = vi + .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') + .mockReturnValue(true); + + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [ + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-2', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, + { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, + ]; + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); + + await act(async () => { + root.render(); + }); + + expect(agentApiMock.listBackgroundCommandActivities).not.toHaveBeenCalled(); + + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); + flushAnimationFrame(); + flushAnimationFrame(); + flushAnimationFrame(); + + expect(releaseSpy).toHaveBeenCalledWith('session-1'); + expect(agentApiMock.listBackgroundCommandActivities).toHaveBeenCalledTimes(1); + expect(agentApiMock.listBackgroundCommandActivities).toHaveBeenCalledWith({ + agentSessionId: 'session-1', + }); + + releaseSpy.mockRestore(); + }); + it('keeps full history projection deferred when latest text visibility signal is missed', async () => { const releaseSpy = vi .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') 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 60ca5c265..2a636a445 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -585,6 +585,10 @@ export const ModernFlowChatContainer: React.FC = ( const shouldBlockHistoryInitialContentInteraction = historyInitialContentKey !== null && historyInitialContentReadyKey !== historyInitialContentKey; + const shouldDeferBackgroundCommandSnapshot = + activeSession?.historyState === 'metadata-only' || + activeSession?.historyState === 'hydrating' || + shouldBlockHistoryInitialContentInteraction; const shouldBlockHistoryTransitionInteraction = shouldBlockHistoryInitialContentInteraction || showHistoryOpenIntentOverlay; @@ -1048,7 +1052,7 @@ export const ModernFlowChatContainer: React.FC = ( useEffect(() => { const agentSessionId = activeSession?.sessionId; - if (!agentSessionId) { + if (!agentSessionId || shouldDeferBackgroundCommandSnapshot) { return; } @@ -1068,7 +1072,7 @@ export const ModernFlowChatContainer: React.FC = ( return () => { cancelled = true; }; - }, [activeSession?.sessionId]); + }, [activeSession?.sessionId, shouldDeferBackgroundCommandSnapshot]); const backgroundCommands = useMemo( () => visibleBackgroundCommandActivitiesForSession( diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts index 36725521d..c3f98f989 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts @@ -123,6 +123,18 @@ describe('selectInitialHistoryRenderWindow', () => { } as VirtualItem; } + function exploreItem(turnIndex: number): VirtualItem { + const id = `turn-${turnIndex}`; + return { + type: 'explore-group', + turnId: id, + data: { + allItems: [{ id: `explore-${id}`, label: `Explore ${turnIndex}` }], + timestamp: turnIndex, + }, + } as VirtualItem; + } + it('keeps only the latest render window on large partial history tails', () => { const items = Array.from({ length: 8 }, (_, index) => [ userItem(index), @@ -138,6 +150,24 @@ describe('selectInitialHistoryRenderWindow', () => { expect(window.omittedEstimatedHeightPx).toBeGreaterThan(0); }); + it('keeps an extra previous turn when the latest turn is user-only', () => { + const items = [ + ...Array.from({ length: 7 }, (_, index) => [ + userItem(index), + exploreItem(index), + modelItem(index), + ]).flat(), + userItem(7), + ]; + + const window = selectInitialHistoryRenderWindow(items); + const renderedTurnIds = Array.from(new Set(window.items.map(item => item.turnId))); + + expect(renderedTurnIds).toEqual(['turn-5', 'turn-6', 'turn-7']); + expect(window.items[0]?.turnId).toBe('turn-5'); + expect(window.omittedEstimatedHeightPx).toBeGreaterThan(0); + }); + it('keeps all items when the partial history tail is already small', () => { const items = [userItem(0), modelItem(0), userItem(1), modelItem(1)]; diff --git a/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts index e09685530..fe373ab25 100644 --- a/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts +++ b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts @@ -5,6 +5,7 @@ export const LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX = 200; export const HISTORICAL_SESSION_DEFAULT_ITEM_HEIGHT_PX = 72; export const HISTORICAL_SESSION_MODEL_ROUND_DEFAULT_ITEM_HEIGHT_PX = 960; export const INITIAL_HISTORY_RENDER_MIN_TURN_COUNT = 2; +const INITIAL_HISTORY_RENDER_USER_ONLY_LATEST_MIN_TURN_COUNT = 3; export const INITIAL_HISTORY_RENDER_MIN_ESTIMATED_HEIGHT_PX = 1400; const USER_MESSAGE_BASE_HEIGHT_PX = 96; const USER_MESSAGE_LINE_HEIGHT_PX = 22; @@ -163,6 +164,34 @@ function uniqueTurnCount(items: VirtualItem[]): number { return turnIds.size; } +function getLatestTurnId(items: VirtualItem[]): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const turnId = items[index]?.turnId; + if (turnId) { + return turnId; + } + } + return null; +} + +function latestTurnHasModelRound(items: VirtualItem[]): boolean { + const latestTurnId = getLatestTurnId(items); + if (!latestTurnId) { + return true; + } + + return items.some(item => + item.turnId === latestTurnId && + item.type === 'model-round' + ); +} + +function getInitialHistoryRenderMinTurnCount(items: VirtualItem[]): number { + return latestTurnHasModelRound(items) + ? INITIAL_HISTORY_RENDER_MIN_TURN_COUNT + : INITIAL_HISTORY_RENDER_USER_ONLY_LATEST_MIN_TURN_COUNT; +} + export function selectInitialHistoryRenderWindow( items: VirtualItem[], options: { @@ -170,7 +199,7 @@ export function selectInitialHistoryRenderWindow( minEstimatedHeightPx?: number; } = {}, ): InitialHistoryRenderWindow { - const minTurnCount = Math.max(1, Math.floor(options.minTurnCount ?? INITIAL_HISTORY_RENDER_MIN_TURN_COUNT)); + const minTurnCount = Math.max(1, Math.floor(options.minTurnCount ?? getInitialHistoryRenderMinTurnCount(items))); const minEstimatedHeightPx = Math.max(0, options.minEstimatedHeightPx ?? INITIAL_HISTORY_RENDER_MIN_ESTIMATED_HEIGHT_PX); const totalEstimatedHeightPx = items.reduce( (total, item) => total + estimateVirtualMessageItemHeight(item), diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index a699cb78d..9798a79cb 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -136,6 +136,30 @@ describe('SessionModule historical session coordination', () => { expect(flowChatStore.switchSession).toHaveBeenCalledWith('history-1'); }); + it('defers activity touch until a metadata-only historical session has hydrated and switched', async () => { + const load = createDeferred(); + const { context, flowChatStore } = createContext(createSession()); + flowChatStore.loadSessionHistory.mockReturnValueOnce(load.promise); + persistenceMocks.touchSessionActivity.mockResolvedValueOnce(undefined); + + const switching = switchChatSession(context, 'history-1'); + await Promise.resolve(); + + expect(persistenceMocks.touchSessionActivity).not.toHaveBeenCalled(); + + load.resolve(); + await switching; + await Promise.resolve(); + + expect(flowChatStore.switchSession).toHaveBeenCalledWith('history-1'); + expect(persistenceMocks.touchSessionActivity).toHaveBeenCalledWith( + 'history-1', + 'D:/workspace/BitFun', + undefined, + undefined, + ); + }); + it('switches immediately when a historical session already has renderable tail content', async () => { const load = createDeferred(); const { context, flowChatStore } = createContext(createSession({ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index be77866dd..8e066072e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -494,14 +494,16 @@ export async function switchChatSession( !isRemoteSession && !hasRenderableSessionContent(session); - touchSessionActivity( - sessionId, - session?.workspacePath, - session?.remoteConnectionId, - session?.remoteSshHost - ).catch(error => { - log.debug('Failed to touch session activity', { sessionId, error }); - }); + const touchActiveSessionInBackground = () => { + touchSessionActivity( + sessionId, + session?.workspacePath, + session?.remoteConnectionId, + session?.remoteSshHost + ).catch(error => { + log.debug('Failed to touch session activity', { sessionId, error }); + }); + }; if (shouldHydrateBeforeSwitch) { try { @@ -524,6 +526,7 @@ export async function switchChatSession( // visible content is restored; already-renderable sessions still switch // immediately and continue full hydration in the background. context.flowChatStore.switchSession(sessionId); + touchActiveSessionInBackground(); startupTrace.markPhase('historical_session_switch', { historical: Boolean(session?.isHistorical), remote: isRemoteSession, diff --git a/tests/e2e/helpers/workspace-helper.ts b/tests/e2e/helpers/workspace-helper.ts index 4e963c750..6e18338be 100644 --- a/tests/e2e/helpers/workspace-helper.ts +++ b/tests/e2e/helpers/workspace-helper.ts @@ -21,6 +21,10 @@ export interface WorkspaceState { workspaceLabels: string[]; } +export interface WorkspaceReadyOptions { + requireWorkspaceLabel?: boolean; +} + /** * Open a workspace through the frontend state layer so the UI stays in sync. */ @@ -82,18 +86,23 @@ export async function getWorkspaceState(): Promise { } /** - * Wait until both frontend state and nav DOM reflect the target workspace. + * Wait until frontend state reflects the target workspace. + * Most flows also require the nav DOM label; perf flows can opt out when the + * measurement only needs an active/opened workspace and must not depend on nav + * expansion rendering. */ export async function waitForWorkspaceReady( workspacePath: string, projectName: string = path.basename(workspacePath), timeout: number = 15000, + options: WorkspaceReadyOptions = {}, ): Promise { + const requireWorkspaceLabel = options.requireWorkspaceLabel ?? true; await browser.waitUntil(async () => { const state = await getWorkspaceState(); return state.currentWorkspacePath === workspacePath && state.openedWorkspacePaths.includes(workspacePath) - && state.workspaceLabels.some(label => label.includes(projectName)); + && (!requireWorkspaceLabel || state.workspaceLabels.some(label => label.includes(projectName))); }, { timeout, interval: 500, @@ -108,10 +117,11 @@ export async function waitForWorkspaceReady( */ export async function openWorkspace( workspacePath: string = process.env.E2E_TEST_WORKSPACE || process.cwd(), + options: WorkspaceReadyOptions = {}, ): Promise { try { await openWorkspaceThroughFrontend(workspacePath); - await waitForWorkspaceReady(workspacePath); + await waitForWorkspaceReady(workspacePath, path.basename(workspacePath), 15000, options); return true; } catch (error) { console.error('[WorkspaceHelper] Failed to open workspace through frontend state:', error); diff --git a/tests/e2e/specs/performance/startup-session-perf.spec.ts b/tests/e2e/specs/performance/startup-session-perf.spec.ts index 5bd3c529f..0213c9a41 100644 --- a/tests/e2e/specs/performance/startup-session-perf.spec.ts +++ b/tests/e2e/specs/performance/startup-session-perf.spec.ts @@ -722,9 +722,9 @@ async function ensurePerformanceWorkspace(startupPage: StartupPage): Promise; + }>; + }; postVisibleObserveMs: number; viewport: LongSessionViewportState; viewportTimelineSummary: LongSessionViewportTimelineSummary; @@ -3661,6 +3678,22 @@ async function collectRapidLongSessionSwitchMeasurement( event.phase === 'react_render_profile' ) ); + const targetClickedAtMs = + clickPlan.find(entry => entry.sessionId === targetSessionId)?.clickedAtMs ?? clickedAtMs; + const sessionBreakdowns = clickPlan.map((entry, index) => { + const sessionEvents = events.filter(event => + typeof event.sessionId === 'string' && + event.sessionId === entry.sessionId + ); + + return { + sessionId: entry.sessionId, + clickIndex: index, + sinceFirstClickMs: entry.clickedAtMs - clickedAtMs, + eventCount: sessionEvents.length, + sessionOpen: summarizeSessionOpen(sessionEvents, entry.clickedAtMs), + }; + }); const verboseTimelineReport = process.env.BITFUN_E2E_PERF_VERBOSE_REPORT === '1'; return { @@ -3675,6 +3708,17 @@ async function collectRapidLongSessionSwitchMeasurement( targetLatestUsableAtMs: latestUsable.usableAtMs, clickToTargetLatestVisibleMs: latestVisible.visibleAtMs - clickedAtMs, clickToTargetLatestUsableMs: latestUsable.usableAtMs - clickedAtMs, + rapidSwitchBreakdown: { + firstClickToTargetClickMs: targetClickedAtMs - clickedAtMs, + target: { + clickedAtMs: targetClickedAtMs, + clickSinceFirstClickMs: targetClickedAtMs - clickedAtMs, + clickToLatestVisibleMs: latestVisible.visibleAtMs - targetClickedAtMs, + clickToLatestUsableMs: latestUsable.usableAtMs - targetClickedAtMs, + latestVisibleToUsableMs: latestUsable.usableAtMs - latestVisible.visibleAtMs, + }, + sessions: sessionBreakdowns, + }, postVisibleObserveMs, viewport, viewportTimelineSummary, @@ -4133,6 +4177,26 @@ describe('Performance telemetry', () => { targetSessionId, clickToTargetLatestVisibleMs: measurement.clickToTargetLatestVisibleMs, clickToTargetLatestUsableMs: measurement.clickToTargetLatestUsableMs, + rapidSwitchBreakdown: { + firstClickToTargetClickMs: measurement.rapidSwitchBreakdown.firstClickToTargetClickMs, + target: { + clickSinceFirstClickMs: measurement.rapidSwitchBreakdown.target.clickSinceFirstClickMs, + clickToLatestVisibleMs: measurement.rapidSwitchBreakdown.target.clickToLatestVisibleMs, + latestVisibleToUsableMs: measurement.rapidSwitchBreakdown.target.latestVisibleToUsableMs, + clickToLatestUsableMs: measurement.rapidSwitchBreakdown.target.clickToLatestUsableMs, + }, + sessions: measurement.rapidSwitchBreakdown.sessions.map(session => ({ + sessionId: session.sessionId, + sinceFirstClickMs: session.sinceFirstClickMs, + eventCount: session.eventCount, + clickToHydrateStartMs: session.sessionOpen.clickToHydrateStartMs, + clickToLatestFrameMs: session.sessionOpen.clickToLatestFrameMs, + clickToHydrateEndMs: session.sessionOpen.clickToHydrateEndMs, + restoreDurationMs: session.sessionOpen.restoreDurationMs, + stateCommitDurationMs: session.sessionOpen.stateCommitDurationMs, + latestFrameSinceHydrateMs: session.sessionOpen.latestFrameSinceHydrateMs, + })), + }, visualStateSummary: { postLatestTextVisibleLoadingEventCount: measurement.visualStateSummary.postLatestTextVisibleLoadingEventCount,