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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const searchStateMock = vi.hoisted(() => ({
const headerPropsMock = vi.hoisted(() => ({
latest: null as Record<string, unknown> | null,
}));
const agentApiMock = vi.hoisted(() => ({
listBackgroundCommandActivities: vi.fn(() => Promise.resolve({ activities: [] })),
}));

vi.mock('react-i18next', () => ({
initReactI18next: {
Expand Down Expand Up @@ -93,6 +96,10 @@ vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({
}),
}));

vi.mock('@/infrastructure/api', () => ({
agentAPI: agentApiMock,
}));

vi.mock('../../utils/acpSession', () => ({
isAcpFlowSession: () => false,
}));
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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<Session>);
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(<ModernFlowChatContainer />);
});

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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,10 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
const shouldBlockHistoryInitialContentInteraction =
historyInitialContentKey !== null &&
historyInitialContentReadyKey !== historyInitialContentKey;
const shouldDeferBackgroundCommandSnapshot =
activeSession?.historyState === 'metadata-only' ||
activeSession?.historyState === 'hydrating' ||
shouldBlockHistoryInitialContentInteraction;
const shouldBlockHistoryTransitionInteraction =
shouldBlockHistoryInitialContentInteraction ||
showHistoryOpenIntentOverlay;
Expand Down Expand Up @@ -1048,7 +1052,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (

useEffect(() => {
const agentSessionId = activeSession?.sessionId;
if (!agentSessionId) {
if (!agentSessionId || shouldDeferBackgroundCommandSnapshot) {
return;
}

Expand All @@ -1068,7 +1072,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
return () => {
cancelled = true;
};
}, [activeSession?.sessionId]);
}, [activeSession?.sessionId, shouldDeferBackgroundCommandSnapshot]);

const backgroundCommands = useMemo(
() => visibleBackgroundCommandActivitiesForSession(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -163,14 +164,42 @@ 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: {
minTurnCount?: number;
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
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<void>();
const { context, flowChatStore } = createContext(createSession({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions tests/e2e/helpers/workspace-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -82,18 +86,23 @@ export async function getWorkspaceState(): Promise<WorkspaceState> {
}

/**
* 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<WorkspaceState> {
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,
Expand All @@ -108,10 +117,11 @@ export async function waitForWorkspaceReady(
*/
export async function openWorkspace(
workspacePath: string = process.env.E2E_TEST_WORKSPACE || process.cwd(),
options: WorkspaceReadyOptions = {},
): Promise<boolean> {
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);
Expand Down
Loading
Loading