Skip to content

Commit 650d778

Browse files
committed
fix(flow-chat): preserve explicit session selection during restore
1 parent dd573aa commit 650d778

5 files changed

Lines changed: 297 additions & 36 deletions

File tree

src/web-ui/src/app/layout/AppLayout.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
276276

277277
// Initialize FlowChatManager
278278
React.useEffect(() => {
279+
let cancelled = false;
279280
const initializeFlowChat = async () => {
280281
if (!currentWorkspace?.rootPath) return;
281282

@@ -305,15 +306,24 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
305306
? currentWorkspace.sshHost
306307
: undefined
307308
);
309+
if (cancelled) {
310+
return;
311+
}
308312

309313
let sessionId: string | undefined;
310314
const { flowChatStore } = await import('@/flow_chat/store/FlowChatStore');
315+
if (cancelled) {
316+
return;
317+
}
311318
if (!hasHistoricalSessions) {
312319
const initialSessionMode =
313320
currentWorkspace.workspaceKind === WorkspaceKind.Assistant
314321
? 'Claw'
315322
: explicitPreferredMode || 'agentic';
316323
sessionId = await flowChatManager.createChatSession({}, initialSessionMode);
324+
if (cancelled) {
325+
return;
326+
}
317327
}
318328

319329
const activeSessionId = sessionId || flowChatStore.getState().activeSessionId;
@@ -326,6 +336,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
326336
sessionStorage.removeItem('pendingProjectDescription');
327337

328338
setTimeout(async () => {
339+
if (cancelled) {
340+
return;
341+
}
329342
try {
330343
const targetSessionId = sessionId || flowChatStore.getState().activeSessionId;
331344

@@ -353,6 +366,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
353366
if (pendingSettings) {
354367
sessionStorage.removeItem('pendingOpenSettings');
355368
setTimeout(async () => {
369+
if (cancelled) {
370+
return;
371+
}
356372
try {
357373
const { quickActions } = await import('@/shared/services/ide-control');
358374
await quickActions.openSettings(pendingSettings);
@@ -362,6 +378,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
362378
}, 500);
363379
}
364380
} catch (error) {
381+
if (cancelled) {
382+
return;
383+
}
365384
log.error('FlowChatManager initialization failed', error);
366385
import('@/shared/notification-system').then(({ notificationService }) => {
367386
notificationService.error(t('appLayout.flowChatInitFailed'), { duration: 5000 });
@@ -370,6 +389,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
370389
};
371390

372391
initializeFlowChat();
392+
return () => {
393+
cancelled = true;
394+
};
373395
}, [
374396
currentWorkspace,
375397
currentWorkspace?.id,

src/web-ui/src/flow_chat/services/FlowChatManager.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,162 @@ describe('FlowChatManager initialization', () => {
153153
expect(storeMocks.store.switchSession).toHaveBeenCalledTimes(1);
154154
expect(storeMocks.store.switchSession).toHaveBeenCalledWith('history-1');
155155
});
156+
157+
it('does not overwrite a user-selected workspace session after initial history restore completes', async () => {
158+
const historyRestore = createDeferred<void>();
159+
const sessions = new Map<string, any>([
160+
['history-1', createHistoricalSession({
161+
sessionId: 'history-1',
162+
title: 'Newest history',
163+
lastActiveAt: 30,
164+
lastFinishedAt: 30,
165+
})],
166+
['history-2', createHistoricalSession({
167+
sessionId: 'history-2',
168+
title: 'User selected history',
169+
lastActiveAt: 10,
170+
lastFinishedAt: 10,
171+
})],
172+
]);
173+
let activeSessionId: string | null = null;
174+
175+
storeMocks.store = {
176+
registerPersistUnreadCompletionCallback: vi.fn(),
177+
loadSessionMetadataPage: vi.fn(async () => ({
178+
sessions: [],
179+
totalTopLevelCount: 2,
180+
hasMore: false,
181+
})),
182+
getState: vi.fn(() => ({
183+
sessions,
184+
activeSessionId,
185+
})),
186+
loadSessionHistory: vi.fn(() => historyRestore.promise),
187+
switchSession: vi.fn((sessionId: string) => {
188+
activeSessionId = sessionId;
189+
}),
190+
};
191+
192+
const manager = FlowChatManager.getInstance();
193+
const initialize = manager.initialize('D:/workspace/BitFun');
194+
195+
await flushAsyncWork();
196+
expect(storeMocks.store.loadSessionHistory).toHaveBeenCalledWith(
197+
'history-1',
198+
'D:/workspace/BitFun',
199+
undefined,
200+
undefined,
201+
undefined,
202+
{ deferFullHistoryUntilActive: true },
203+
);
204+
205+
activeSessionId = 'history-2';
206+
historyRestore.resolve();
207+
208+
await expect(initialize).resolves.toBe(true);
209+
210+
expect(storeMocks.store.switchSession).not.toHaveBeenCalled();
211+
expect(activeSessionId).toBe('history-2');
212+
});
213+
214+
it('does not let a stale workspace initialization overwrite a newer active workspace', async () => {
215+
const historyRestore = createDeferred<void>();
216+
const sessions = new Map<string, any>([
217+
['history-1', createHistoricalSession()],
218+
['other-1', createHistoricalSession({
219+
sessionId: 'other-1',
220+
title: 'Other workspace',
221+
workspacePath: 'D:/workspace/Other',
222+
lastActiveAt: 40,
223+
lastFinishedAt: 40,
224+
})],
225+
]);
226+
let activeSessionId: string | null = null;
227+
228+
storeMocks.store = {
229+
registerPersistUnreadCompletionCallback: vi.fn(),
230+
loadSessionMetadataPage: vi.fn(async () => ({
231+
sessions: [],
232+
totalTopLevelCount: 1,
233+
hasMore: false,
234+
})),
235+
getState: vi.fn(() => ({
236+
sessions,
237+
activeSessionId,
238+
})),
239+
loadSessionHistory: vi.fn(() => historyRestore.promise),
240+
switchSession: vi.fn((sessionId: string) => {
241+
activeSessionId = sessionId;
242+
}),
243+
};
244+
245+
const manager = FlowChatManager.getInstance();
246+
const initialize = manager.initialize('D:/workspace/BitFun');
247+
248+
await flushAsyncWork();
249+
activeSessionId = 'other-1';
250+
(manager as unknown as { context: { currentWorkspacePath: string | null } })
251+
.context.currentWorkspacePath = 'D:/workspace/Other';
252+
historyRestore.resolve();
253+
254+
await expect(initialize).resolves.toBe(true);
255+
256+
expect(storeMocks.store.switchSession).not.toHaveBeenCalled();
257+
expect(activeSessionId).toBe('other-1');
258+
expect((manager as unknown as { context: { currentWorkspacePath: string | null } })
259+
.context.currentWorkspacePath).toBe('D:/workspace/Other');
260+
});
261+
262+
it('does not let an older workspace initialization switch after a newer workspace initialize starts', async () => {
263+
const bitfunHistoryRestore = createDeferred<void>();
264+
const sessions = new Map<string, any>([
265+
['history-1', createHistoricalSession()],
266+
['other-1', createHistoricalSession({
267+
sessionId: 'other-1',
268+
title: 'Other workspace',
269+
workspacePath: 'D:/workspace/Other',
270+
lastActiveAt: 40,
271+
lastFinishedAt: 40,
272+
})],
273+
]);
274+
let activeSessionId: string | null = null;
275+
276+
storeMocks.store = {
277+
registerPersistUnreadCompletionCallback: vi.fn(),
278+
loadSessionMetadataPage: vi.fn(async (
279+
workspacePath: string,
280+
) => ({
281+
sessions: [],
282+
totalTopLevelCount: workspacePath === 'D:/workspace/BitFun' ? 1 : 0,
283+
hasMore: false,
284+
})),
285+
getState: vi.fn(() => ({
286+
sessions,
287+
activeSessionId,
288+
})),
289+
loadSessionHistory: vi.fn((sessionId: string) => {
290+
if (sessionId === 'history-1') {
291+
return bitfunHistoryRestore.promise;
292+
}
293+
return Promise.resolve();
294+
}),
295+
switchSession: vi.fn((sessionId: string) => {
296+
activeSessionId = sessionId;
297+
}),
298+
};
299+
300+
const manager = FlowChatManager.getInstance();
301+
const bitfunInitialize = manager.initialize('D:/workspace/BitFun');
302+
303+
await flushAsyncWork();
304+
await expect(manager.initialize('D:/workspace/Other')).resolves.toBe(true);
305+
306+
bitfunHistoryRestore.resolve();
307+
await expect(bitfunInitialize).resolves.toBe(true);
308+
309+
expect(storeMocks.store.switchSession).toHaveBeenCalledWith('other-1');
310+
expect(storeMocks.store.switchSession).not.toHaveBeenCalledWith('history-1');
311+
expect((manager as unknown as { context: { currentWorkspacePath: string | null } })
312+
.context.currentWorkspacePath).toBe('D:/workspace/Other');
313+
});
156314
});

src/web-ui/src/flow_chat/services/FlowChatManager.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class FlowChatManager {
5454
private eventListenerInitializationPromise: Promise<void> | null = null;
5555
private eventListenerCleanup: (() => void) | null = null;
5656
private initializationRequests = new Map<string, Promise<boolean>>();
57+
private latestInitializationRequestKey: string | null = null;
5758

5859
private constructor() {
5960
this.context = {
@@ -107,12 +108,14 @@ export class FlowChatManager {
107108
remoteSshHost,
108109
);
109110
const existingRequest = this.initializationRequests.get(requestKey);
111+
this.latestInitializationRequestKey = requestKey;
110112
if (existingRequest) {
111113
return existingRequest;
112114
}
113115

114116
let request: Promise<boolean>;
115117
request = this.initializeWorkspace(
118+
requestKey,
116119
workspacePath,
117120
preferredMode,
118121
remoteConnectionId,
@@ -141,6 +144,7 @@ export class FlowChatManager {
141144
}
142145

143146
private async initializeWorkspace(
147+
requestKey: string,
144148
workspacePath: string,
145149
preferredMode?: string,
146150
remoteConnectionId?: string,
@@ -214,13 +218,19 @@ export class FlowChatManager {
214218
workspaceSessions.length > 0 ||
215219
initialMetadataPage.totalTopLevelCount > 0 ||
216220
initialMetadataPage.sessions.length > 0;
221+
const isCurrentInitializationRequest = () =>
222+
this.latestInitializationRequestKey === requestKey;
217223
const activeSession = state.activeSessionId
218224
? state.sessions.get(state.activeSessionId) ?? null
219225
: null;
220226
const activeSessionBelongsToWorkspace =
221227
!!activeSession && sessionMatchesWorkspace(activeSession);
228+
const activeSessionIdAtAutoSelectStart = state.activeSessionId;
222229

223230
if (hasHistoricalSessions && !activeSessionBelongsToWorkspace) {
231+
if (!isCurrentInitializationRequest()) {
232+
return hasHistoricalSessions;
233+
}
224234
const sortedWorkspaceSessions = [...workspaceSessions].sort(compareSessionsForDisplay);
225235
const latestSession = (preferredMode
226236
? sortedWorkspaceSessions.find(session => session.mode === preferredMode)
@@ -242,10 +252,33 @@ export class FlowChatManager {
242252
);
243253
}
244254

255+
if (!isCurrentInitializationRequest()) {
256+
return hasHistoricalSessions;
257+
}
258+
259+
const currentState = this.context.flowChatStore.getState();
260+
const currentActiveSession = currentState.activeSessionId
261+
? currentState.sessions.get(currentState.activeSessionId) ?? null
262+
: null;
263+
const currentActiveSessionBelongsToWorkspace =
264+
!!currentActiveSession && sessionMatchesWorkspace(currentActiveSession);
265+
const activeSessionChangedDuringAutoSelect =
266+
currentState.activeSessionId !== activeSessionIdAtAutoSelectStart &&
267+
currentState.activeSessionId !== null;
268+
if (currentActiveSessionBelongsToWorkspace) {
269+
this.context.currentWorkspacePath = workspacePath;
270+
return hasHistoricalSessions;
271+
}
272+
if (activeSessionChangedDuringAutoSelect) {
273+
return hasHistoricalSessions;
274+
}
275+
245276
this.context.flowChatStore.switchSession(latestSession.sessionId);
246277
}
247278

248-
this.context.currentWorkspacePath = workspacePath;
279+
if (isCurrentInitializationRequest()) {
280+
this.context.currentWorkspacePath = workspacePath;
281+
}
249282

250283
return hasHistoricalSessions;
251284
} catch (error) {

tests/e2e/scripts/generate-long-session-fixture.mjs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ function parseArgs(argv) {
100100
if (!options.sessionPrefix.trim()) {
101101
throw new Error('--session-prefix cannot be empty');
102102
}
103-
if (!['explore-only', 'mixed-visible', 'dense-visible'].includes(options.scenario)) {
104-
throw new Error('--scenario must be one of: explore-only, mixed-visible, dense-visible');
103+
if (!['explore-only', 'mixed-visible', 'dense-visible', 'user-only-latest'].includes(options.scenario)) {
104+
throw new Error('--scenario must be one of: explore-only, mixed-visible, dense-visible, user-only-latest');
105105
}
106106
return options;
107107
}
@@ -122,7 +122,7 @@ Options:
122122
--tool-items <n> Tool item count per turn. Default: 2
123123
--dense-groups <n> Groups in the latest dense-visible turn. Default: 160
124124
--session-prefix <text> Session id prefix. Default: perf-long-session
125-
--scenario <name> Fixture shape: mixed-visible, dense-visible, or explore-only. Default: mixed-visible
125+
--scenario <name> Fixture shape: mixed-visible, dense-visible, explore-only, or user-only-latest. Default: mixed-visible
126126
--bitfun-home <path> BitFun home root. Default: BITFUN_HOME or ~/.bitfun
127127
--cleanup Remove generated sessions for the prefix.
128128
`);
@@ -196,7 +196,7 @@ function makeMetadata({ sessionId, sessionName, workspacePath, createdAt, lastAc
196196
createdAt,
197197
lastActiveAt,
198198
turnCount,
199-
messageCount: turnCount * 2,
199+
messageCount: scenario === 'user-only-latest' ? (turnCount * 2) - 1 : turnCount * 2,
200200
toolCallCount: turnCount * toolItems,
201201
status: 'active',
202202
terminalSessionId: null,
@@ -415,7 +415,9 @@ function makeTurn({
415415
const turnId = `${sessionId}-turn-${String(turnIndex).padStart(4, '0')}`;
416416
const toolItemsData = makeToolItems({ turnId, turnIndex, timestamp, toolResultChars, toolItems });
417417
const isLatestTurn = turnIndex === totalTurns - 1;
418-
const modelRounds = scenario === 'dense-visible' && isLatestTurn
418+
const modelRounds = scenario === 'user-only-latest' && isLatestTurn
419+
? []
420+
: scenario === 'dense-visible' && isLatestTurn
419421
? makeDenseLatestModelRounds({
420422
turnId,
421423
turnIndex,

0 commit comments

Comments
 (0)