From 856d4c2adfb151a725cff1181237f0d970153973 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Tue, 23 Jun 2026 21:36:02 +0800 Subject: [PATCH] fix(web-ui): stabilize flow chat session activation - extract main-session activation from BTW pane helpers - avoid auto-selecting child subagent sessions during workspace restore - return to the welcome panel when deleting or archiving the current session - align batch archive and delete flows with shared session manager behavior --- .../components/NavPanel/NavSearchDialog.tsx | 2 +- .../sections/sessions/SessionsSection.tsx | 11 +- .../sections/workspaces/WorkspaceItem.tsx | 2 +- .../workspaces/WorkspaceSessionBatchModal.tsx | 51 +--- .../panels/content-canvas/ContentCanvas.tsx | 3 +- .../review-platform/ReviewPlatformPanel.tsx | 2 +- .../src/app/layout/FloatingMiniChat.tsx | 9 +- .../profile/views/AssistantQuickInput.tsx | 2 +- .../openAgentCompanionSession.test.ts | 15 +- .../app/services/openAgentCompanionSession.ts | 6 +- .../src/flow_chat/components/ChatInput.tsx | 2 +- .../modern/ModernFlowChatContainer.tsx | 2 +- .../modern/SessionFilesBadge.test.tsx | 2 +- .../components/modern/SessionFilesBadge.tsx | 2 +- .../subagent/SubagentProjectionView.test.tsx | 2 +- .../subagent/SubagentProjectionView.tsx | 2 +- .../components/toolbar-mode/ToolbarMode.tsx | 10 +- .../deep-review/launch/DeepReviewService.ts | 2 +- .../services/DeepReviewService.test.ts | 2 +- .../services/FlowChatManager.test.ts | 60 ++++- .../src/flow_chat/services/FlowChatManager.ts | 26 +- .../{openBtwSession.ts => btwSessionPane.ts} | 35 --- .../flow-chat-manager/SessionModule.test.ts | 240 +++++++++++++++++- .../flow-chat-manager/SessionModule.ts | 59 ++++- .../services/flow-chat-manager/index.ts | 1 + .../flow_chat/services/openBtwSession.test.ts | 35 ++- .../flow_chat/services/sessionActivation.ts | 59 +++++ .../src/flow_chat/store/FlowChatStore.test.ts | 75 ++++++ .../src/flow_chat/store/FlowChatStore.ts | 26 +- .../tool-cards/ReviewSessionSummaryCard.tsx | 3 +- .../tool-cards/TaskToolDisplay.test.tsx | 2 +- .../flow_chat/tool-cards/TaskToolDisplay.tsx | 2 +- 32 files changed, 624 insertions(+), 128 deletions(-) rename src/web-ui/src/flow_chat/services/{openBtwSession.ts => btwSessionPane.ts} (89%) create mode 100644 src/web-ui/src/flow_chat/services/sessionActivation.ts diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx index 27a38b3330..86e2952810 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx @@ -10,7 +10,7 @@ import { useMyAgentStore } from '@/app/scenes/my-agent/myAgentStore'; import { useNurseryStore } from '@/app/scenes/profile/nurseryStore'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { findWorkspaceForSession } from '@/flow_chat/utils/workspaceScope'; -import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; import type { SessionMetadata } from '@/shared/types/session-history'; import type { WorkspaceInfo } from '@/shared/types'; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index d5084bb0ff..94b0d8a3de 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -19,9 +19,9 @@ import { createLogger } from '@/shared/utils/logger'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { openBtwSessionInAuxPane, - openMainSession, selectActiveBtwSessionTab, -} from '@/flow_chat/services/openBtwSession'; +} from '@/flow_chat/services/btwSessionPane'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { dispatchHistorySessionOpenIntent, shouldShowHistorySessionOpenIntent, @@ -47,7 +47,6 @@ import type { BackgroundSubagentActivityItem, } from '@/flow_chat/utils/backgroundSubagentActivity'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; -import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; import { scheduleAfterStartupPaint, scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; import { @@ -803,15 +802,13 @@ const SessionsSection: React.FC = ({ ); if (!confirmed) return; try { - await sessionAPI.archiveSession(sessionId, workspacePath || '', remoteConnectionId || undefined, remoteSshHost || undefined); - // Remove from in-memory state only — do NOT delete from disk - flowChatManager.discardLocalSession(sessionId); + await flowChatManager.archiveChatSession(sessionId); window.dispatchEvent(new CustomEvent('bitfun:session-archived')); } catch (err) { log.error('Failed to archive session', err); } }, - [workspacePath, remoteConnectionId, remoteSshHost, t] + [t] ); const handleStartEdit = useCallback( diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index fe7f7d3051..34eda2fd55 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -19,7 +19,7 @@ import { workspaceAPI } from '@/infrastructure/api'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { notificationService } from '@/shared/notification-system'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; -import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { getHistorySessionOpenTransitionSnapshot, subscribeHistorySessionOpenTransition, diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx index 0c0067dd14..58f4ea57fb 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx @@ -220,20 +220,10 @@ const WorkspaceSessionBatchModal: React.FC = ({ setActionKind('archive'); try { const results = await Promise.allSettled( - selectedIds.map(sessionId => - sessionAPI.archiveSession( - sessionId, - workspacePath, - remoteConnectionId || undefined, - remoteSshHost || undefined - ) - ) + selectedIds.map(sessionId => flowChatManager.archiveChatSession(sessionId)) ); const successCount = results.filter(result => result.status === 'fulfilled').length; if (successCount > 0) { - selectedIds.forEach(sessionId => { - flowChatManager.discardLocalSession(sessionId); - }); await refreshWorkspaceSessions(); window.dispatchEvent(new CustomEvent('bitfun:session-archived')); notificationService.success(t('nav.sessions.archivedAll', { count: successCount }), { duration: 3000 }); @@ -251,8 +241,6 @@ const WorkspaceSessionBatchModal: React.FC = ({ }, [ loadSessions, refreshWorkspaceSessions, - remoteConnectionId, - remoteSshHost, selectedCount, selectedSessionIds, t, @@ -274,32 +262,23 @@ const WorkspaceSessionBatchModal: React.FC = ({ const deletionPlan = getDeletionPlan(selectedSessionIds, sessions); setActionKind('delete'); try { - const results = await Promise.allSettled( - deletionPlan.allIds.map(sessionId => - sessionAPI.deleteSession( - sessionId, - workspacePath, - remoteConnectionId || undefined, - remoteSshHost || undefined - ) - ) - ); const successIds = new Set(); - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - successIds.add(deletionPlan.allIds[index]); - } - }); - const removableRootIds = deletionPlan.rootIds.filter(rootId => { + for (const rootId of deletionPlan.rootIds) { const cascadeIds = getDeletionPlan(new Set([rootId]), sessions).allIds; - return cascadeIds.every(id => successIds.has(id)); - }); + try { + await flowChatManager.deleteChatSession(rootId); + cascadeIds.forEach(id => successIds.add(id)); + } catch (error) { + log.error('Failed to delete selected root session', { + error, + rootSessionId: rootId, + workspacePath, + }); + } + } - if (removableRootIds.length > 0) { - removableRootIds.forEach(sessionId => { - flowChatManager.discardLocalSession(sessionId); - }); + if (successIds.size > 0) { await refreshWorkspaceSessions(); notificationService.success(t('nav.sessions.deletedSelected', { count: successIds.size }), { duration: 3000 }); } @@ -316,8 +295,6 @@ const WorkspaceSessionBatchModal: React.FC = ({ }, [ loadSessions, refreshWorkspaceSessions, - remoteConnectionId, - remoteSshHost, selectedCount, selectedSessionIds, sessions, diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index a3bb5d1627..fc4eee4399 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -12,7 +12,8 @@ import { useCanvasStore } from './stores'; import { useTabLifecycle, useKeyboardShortcuts, usePanelTabCoordinator } from './hooks'; import type { AnchorPosition } from './types'; import { TAB_EVENTS } from './types'; -import { openMainSession, selectActiveBtwSessionTab } from '@/flow_chat/services/openBtwSession'; +import { selectActiveBtwSessionTab } from '@/flow_chat/services/btwSessionPane'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { isSamePath } from '@/shared/utils/pathUtils'; import './ContentCanvas.scss'; export interface ContentCanvasProps { diff --git a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx index d5670f2354..c043d366bc 100644 --- a/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx +++ b/src/web-ui/src/app/components/panels/review-platform/ReviewPlatformPanel.tsx @@ -27,7 +27,7 @@ import { reviewPlatformAPI, systemAPI, type ReviewPlatformAccount, type ReviewPl import { createLogger } from '@/shared/utils/logger'; import { notificationService } from '@/shared/notification-system'; import { i18nService } from '@/infrastructure/i18n'; -import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import type { FlowToolItem, Session } from '@/flow_chat/types/flow-chat'; import { findLatestCodeReviewResult, summarizeCodeReviewResult } from '@/flow_chat/utils/reviewSessionSummary'; diff --git a/src/web-ui/src/app/layout/FloatingMiniChat.tsx b/src/web-ui/src/app/layout/FloatingMiniChat.tsx index 013daf6d09..5518173770 100644 --- a/src/web-ui/src/app/layout/FloatingMiniChat.tsx +++ b/src/web-ui/src/app/layout/FloatingMiniChat.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import { flowChatStore } from '../../flow_chat/store/FlowChatStore'; import { syncSessionToModernStore } from '../../flow_chat/services/storeSync'; +import { activateMainSession } from '../../flow_chat/services/sessionActivation'; import { useToolbarModeContext } from '../../flow_chat/components/toolbar-mode/ToolbarModeContext'; import type { FlowChatState } from '../../flow_chat/types/flow-chat'; import { compareSessionsForDisplay } from '../../flow_chat/utils/sessionOrdering'; @@ -113,9 +114,11 @@ export const FloatingMiniChat: React.FC = () => { const handleSwitchSession = useCallback((e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); e.preventDefault(); - flowChatStore.switchSession(sessionId); - syncSessionToModernStore(sessionId); - setShowSessionPicker(false); + void activateMainSession(sessionId).then((activated) => { + if (activated) { + setShowSessionPicker(false); + } + }); }, []); const handleCancel = useCallback(() => { diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx index 76fd2bb360..04b5fcd3ec 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { IconButton, Textarea } from '@/component-library'; import { ModelSelector } from '@/flow_chat/components/ModelSelector'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; -import { openMainSession } from '@/flow_chat/services/openBtwSession'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { useImeEnterGuard } from '@/flow_chat/hooks/useImeEnterGuard'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { notificationService } from '@/shared/notification-system'; diff --git a/src/web-ui/src/app/services/openAgentCompanionSession.test.ts b/src/web-ui/src/app/services/openAgentCompanionSession.test.ts index a3388dec2a..876f984960 100644 --- a/src/web-ui/src/app/services/openAgentCompanionSession.test.ts +++ b/src/web-ui/src/app/services/openAgentCompanionSession.test.ts @@ -5,13 +5,17 @@ import type { Session } from '@/flow_chat/types/flow-chat'; const mocks = vi.hoisted(() => ({ openBtwSessionInAuxPane: vi.fn(), openMainSession: vi.fn(() => Promise.resolve()), - switchSession: vi.fn(), + activateMainSession: vi.fn(() => Promise.resolve(true)), sessions: new Map(), })); -vi.mock('@/flow_chat/services/openBtwSession', () => ({ +vi.mock('@/flow_chat/services/btwSessionPane', () => ({ openBtwSessionInAuxPane: (...args: unknown[]) => mocks.openBtwSessionInAuxPane(...args), +})); + +vi.mock('@/flow_chat/services/sessionActivation', () => ({ openMainSession: (...args: unknown[]) => mocks.openMainSession(...args), + activateMainSession: (...args: unknown[]) => mocks.activateMainSession(...args), })); vi.mock('@/flow_chat/store/FlowChatStore', () => ({ @@ -20,7 +24,6 @@ vi.mock('@/flow_chat/store/FlowChatStore', () => ({ getState: () => ({ sessions: mocks.sessions, }), - switchSession: (...args: unknown[]) => mocks.switchSession(...args), }), }, })); @@ -43,7 +46,7 @@ describe('openAgentCompanionSession', () => { beforeEach(() => { mocks.openBtwSessionInAuxPane.mockClear(); mocks.openMainSession.mockClear(); - mocks.switchSession.mockClear(); + mocks.activateMainSession.mockClear(); mocks.sessions.clear(); }); @@ -64,7 +67,7 @@ describe('openAgentCompanionSession', () => { parentSessionId: 'parent-session', workspacePath: 'D:/workspace/project', }); - expect(mocks.switchSession).not.toHaveBeenCalled(); + expect(mocks.activateMainSession).not.toHaveBeenCalled(); }); it('keeps regular sessions on the main chat route', async () => { @@ -73,7 +76,7 @@ describe('openAgentCompanionSession', () => { const opened = await openAgentCompanionSession('session-1'); expect(opened).toBe(true); - expect(mocks.switchSession).toHaveBeenCalledWith('session-1'); + expect(mocks.activateMainSession).toHaveBeenCalledWith('session-1'); expect(mocks.openMainSession).not.toHaveBeenCalled(); expect(mocks.openBtwSessionInAuxPane).not.toHaveBeenCalled(); }); diff --git a/src/web-ui/src/app/services/openAgentCompanionSession.ts b/src/web-ui/src/app/services/openAgentCompanionSession.ts index 746ceb2735..66c1f8bb3c 100644 --- a/src/web-ui/src/app/services/openAgentCompanionSession.ts +++ b/src/web-ui/src/app/services/openAgentCompanionSession.ts @@ -1,5 +1,6 @@ import { FlowChatStore } from '@/flow_chat/store/FlowChatStore'; -import { openBtwSessionInAuxPane, openMainSession } from '@/flow_chat/services/openBtwSession'; +import { openBtwSessionInAuxPane } from '@/flow_chat/services/btwSessionPane'; +import { activateMainSession, openMainSession } from '@/flow_chat/services/sessionActivation'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; export async function openAgentCompanionSession(sessionId: string): Promise { @@ -22,6 +23,5 @@ export async function openAgentCompanionSession(sessionId: string): Promise ({ createBtwChildSession: vi.fn(), })); -vi.mock('../../services/openBtwSession', () => ({ +vi.mock('../../services/btwSessionPane', () => ({ openBtwSessionInAuxPane: vi.fn(), })); diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx index b556e40fda..a3a6461352 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx @@ -25,7 +25,7 @@ import { notificationService } from '../../../shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { runWithConcurrencyLimit } from '@/shared/utils/runWithConcurrencyLimit'; import { createBtwChildSession } from '../../services/BtwThreadService'; -import { openBtwSessionInAuxPane } from '../../services/openBtwSession'; +import { openBtwSessionInAuxPane } from '../../services/btwSessionPane'; import { buildDeepReviewLaunchFromSessionFiles, buildDeepReviewPreviewFromSessionFiles, diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx index 4b4a6052e4..b5c6c7dc1f 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.test.tsx @@ -52,7 +52,7 @@ vi.mock('../../store/FlowChatStore', () => ({ }, })); -vi.mock('../../services/openBtwSession', () => ({ +vi.mock('../../services/btwSessionPane', () => ({ ensureBtwSessionAvailable: (...args: unknown[]) => ensureBtwSessionAvailableMock(...args), })); diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx index 2174f7d20c..b5db5c1ca0 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx @@ -8,7 +8,7 @@ import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; import { SmoothHeightCollapse } from '../modern/SmoothHeightCollapse'; import { FlowChatStore } from '../../store/FlowChatStore'; import { getSubagentProjectionState } from '../../utils/subagentProjection'; -import { ensureBtwSessionAvailable } from '../../services/openBtwSession'; +import { ensureBtwSessionAvailable } from '../../services/btwSessionPane'; import './SubagentProjectionView.scss'; interface SubagentProjectionViewProps { diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx index 19f90e84b3..5a3df2891f 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx @@ -24,7 +24,7 @@ import { } from 'lucide-react'; import { useToolbarModeContext } from './ToolbarModeContext'; import { flowChatStore } from '../../store/FlowChatStore'; -import { syncSessionToModernStore } from '../../services/storeSync'; +import { activateMainSession } from '../../services/sessionActivation'; import { FlowChatState } from '../../types/flow-chat'; import { compareSessionsForDisplay } from '../../utils/sessionOrdering'; import { createLogger } from '@/shared/utils/logger'; @@ -235,9 +235,11 @@ export const ToolbarMode: React.FC = () => { const handleSwitchSession = useCallback((e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); e.preventDefault(); - flowChatStore.switchSession(sessionId); - syncSessionToModernStore(sessionId); - setShowSessionPicker(false); + void activateMainSession(sessionId).then((activated) => { + if (activated) { + setShowSessionPicker(false); + } + }); }, []); const handleCancel = useCallback(() => { diff --git a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts index 8e28846119..4b5f52dbbc 100644 --- a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts +++ b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts @@ -1,7 +1,7 @@ import { agentAPI } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { createBtwChildSession } from '../../services/BtwThreadService'; -import { closeBtwSessionInAuxPane, openBtwSessionInAuxPane } from '../../services/openBtwSession'; +import { closeBtwSessionInAuxPane, openBtwSessionInAuxPane } from '../../services/btwSessionPane'; import { FlowChatManager } from '../../services/FlowChatManager'; import { flowChatStore } from '../../store/FlowChatStore'; import { insertReviewSessionSummaryMarker } from '../../services/ReviewSessionMarkerService'; diff --git a/src/web-ui/src/flow_chat/services/DeepReviewService.test.ts b/src/web-ui/src/flow_chat/services/DeepReviewService.test.ts index 7e273b5ac5..bb091f8c7a 100644 --- a/src/web-ui/src/flow_chat/services/DeepReviewService.test.ts +++ b/src/web-ui/src/flow_chat/services/DeepReviewService.test.ts @@ -41,7 +41,7 @@ vi.mock('./BtwThreadService', () => ({ createBtwChildSession: (...args: any[]) => mockCreateBtwChildSession(...args), })); -vi.mock('./openBtwSession', () => ({ +vi.mock('./btwSessionPane', () => ({ closeBtwSessionInAuxPane: (...args: any[]) => mockCloseBtwSessionInAuxPane(...args), openBtwSessionInAuxPane: (...args: any[]) => mockOpenBtwSessionInAuxPane(...args), })); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts index d3d62257dc..8c8e059d7f 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts @@ -4,6 +4,7 @@ import { FlowChatManager } from './FlowChatManager'; const storeMocks = vi.hoisted(() => ({ store: {} as any, initializeEventListeners: vi.fn(), + switchChatSession: vi.fn(), eventBatchers: [] as Array<{ flushNow: ReturnType; destroy: ReturnType; @@ -53,8 +54,9 @@ vi.mock('./flow-chat-manager', () => ({ saveAllInProgressTurns: vi.fn(), immediateSaveDialogTurn: vi.fn(), createChatSession: vi.fn(), - switchChatSession: vi.fn(), + switchChatSession: (...args: unknown[]) => storeMocks.switchChatSession(...args), deleteChatSession: vi.fn(), + archiveChatSession: vi.fn(), renameChatSessionTitle: vi.fn(), forkChatSession: vi.fn(), cleanupSaveState: vi.fn(), @@ -115,6 +117,9 @@ describe('FlowChatManager initialization', () => { vi.clearAllMocks(); storeMocks.eventBatchers.length = 0; storeMocks.initializeEventListeners.mockResolvedValue(() => {}); + storeMocks.switchChatSession.mockImplementation(async (context: any, sessionId: string) => { + context.flowChatStore.switchSession(sessionId); + }); }); it('flushes and destroys the batcher when the singleton is disposed', () => { @@ -269,7 +274,6 @@ describe('FlowChatManager initialization', () => { undefined, undefined, undefined, - { deferFullHistoryUntilActive: true }, ); activeSessionId = 'history-2'; @@ -381,4 +385,56 @@ describe('FlowChatManager initialization', () => { expect((manager as unknown as { context: { currentWorkspacePath: string | null } }) .context.currentWorkspacePath).toBe('D:/workspace/Other'); }); + + it('ignores child subagent sessions when auto-selecting a workspace session', async () => { + const sessions = new Map([ + ['parent-1', createHistoricalSession({ + sessionId: 'parent-1', + title: 'Parent session', + isHistorical: false, + historyState: 'ready', + createdAt: 10, + lastFinishedAt: 30, + workspacePath: 'D:/workspace/BitFun', + sessionKind: 'normal', + })], + ['subagent-1', createHistoricalSession({ + sessionId: 'subagent-1', + title: 'Subagent session', + isHistorical: false, + historyState: 'ready', + createdAt: 40, + lastFinishedAt: undefined, + workspacePath: 'D:/workspace/BitFun', + sessionKind: 'subagent', + parentSessionId: 'parent-1', + mode: 'Explore', + })], + ]); + let activeSessionId: string | null = null; + + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + loadSessionMetadataPage: vi.fn(async () => ({ + sessions: [], + totalTopLevelCount: 2, + hasMore: false, + })), + getState: vi.fn(() => ({ + sessions, + activeSessionId, + })), + loadSessionHistory: vi.fn(async () => undefined), + switchSession: vi.fn((sessionId: string) => { + activeSessionId = sessionId; + }), + }; + + const manager = FlowChatManager.getInstance(); + await expect(manager.initialize('D:/workspace/BitFun')).resolves.toBe(true); + + expect(storeMocks.store.switchSession).toHaveBeenCalledTimes(1); + expect(storeMocks.store.switchSession).toHaveBeenCalledWith('parent-1'); + expect(storeMocks.store.switchSession).not.toHaveBeenCalledWith('subagent-1'); + }); }); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 3787d0c5fd..620e03a542 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -15,10 +15,12 @@ import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; import { createLogger } from '@/shared/utils/logger'; import type { WorkspaceInfo } from '@/shared/types'; +import type { Session } from '../types/flow-chat'; import { compareSessionsForDisplay, sessionBelongsToWorkspaceNavRow, } from '../utils/sessionOrdering'; +import { resolveSessionRelationship } from '../utils/sessionMetadata'; import type { FlowChatContext, SessionConfig, DialogTurn } from './flow-chat-manager/types'; import { @@ -28,6 +30,7 @@ import { preloadHistoricalSessionForOpen as preloadHistoricalSessionForOpenModule, switchChatSession as switchChatSessionModule, deleteChatSession as deleteChatSessionModule, + archiveChatSession as archiveChatSessionModule, renameChatSessionTitle as renameChatSessionTitleModule, forkChatSession as forkChatSessionModule, cleanupSaveState, @@ -205,9 +208,19 @@ export class FlowChatManager { remoteSshHost ); }; + const isAutoSelectableWorkspaceSession = ( + session: Pick + ) => { + if (session.isTransient) { + return false; + } + return !resolveSessionRelationship(session).displayAsChild; + }; let state = this.context.flowChatStore.getState(); - let workspaceSessions = Array.from(state.sessions.values()).filter(sessionMatchesWorkspace); + let workspaceSessions = Array + .from(state.sessions.values()) + .filter(session => sessionMatchesWorkspace(session) && isAutoSelectableWorkspaceSession(session)); if ( preferredMode && initialMetadataPage.hasMore && @@ -227,7 +240,9 @@ export class FlowChatManager { return false; } state = this.context.flowChatStore.getState(); - workspaceSessions = Array.from(state.sessions.values()).filter(sessionMatchesWorkspace); + workspaceSessions = Array + .from(state.sessions.values()) + .filter(session => sessionMatchesWorkspace(session) && isAutoSelectableWorkspaceSession(session)); if (workspaceSessions.some(session => session.mode === preferredMode) || !nextPage.hasMore) { break; } @@ -268,7 +283,6 @@ export class FlowChatManager { undefined, latestSession.remoteConnectionId, latestSession.remoteSshHost, - { deferFullHistoryUntilActive: true }, ); if (this.disposed) { return false; @@ -296,7 +310,7 @@ export class FlowChatManager { return hasHistoricalSessions; } - this.context.flowChatStore.switchSession(latestSession.sessionId); + await switchChatSessionModule(this.context, latestSession.sessionId); } if (isCurrentInitializationRequest()) { @@ -438,6 +452,10 @@ export class FlowChatManager { return deleteChatSessionModule(this.context, sessionId); } + async archiveChatSession(sessionId: string): Promise { + return archiveChatSessionModule(this.context, sessionId); + } + public discardLocalSession(sessionId: string): string[] { const removedSessionIds = this.context.flowChatStore.removeSession(sessionId); removedSessionIds.forEach(id => { diff --git a/src/web-ui/src/flow_chat/services/openBtwSession.ts b/src/web-ui/src/flow_chat/services/btwSessionPane.ts similarity index 89% rename from src/web-ui/src/flow_chat/services/openBtwSession.ts rename to src/web-ui/src/flow_chat/services/btwSessionPane.ts index 9dffd2d891..3503f731d7 100644 --- a/src/web-ui/src/flow_chat/services/openBtwSession.ts +++ b/src/web-ui/src/flow_chat/services/btwSessionPane.ts @@ -1,13 +1,9 @@ import { i18nService } from '@/infrastructure/i18n'; -import { appManager } from '@/app/services/AppManager'; -import { useSceneStore } from '@/app/stores/sceneStore'; import { createTab } from '@/shared/utils/tabUtils'; import type { PanelContent } from '@/app/components/panels/base/types'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import type { CanvasTab } from '@/app/components/panels/content-canvas/types'; import { flowChatStore } from '../store/FlowChatStore'; -import { flowChatManager } from './FlowChatManager'; -import { syncSessionToModernStore } from './storeSync'; import { resolveSessionTitle } from '../utils/sessionTitle'; export const BTW_SESSION_PANEL_TYPE = 'btw-session' as const; @@ -197,37 +193,6 @@ export function ensureBtwSessionAvailable(params: EnsureBtwSessionAvailableParam ); } -export async function openMainSession( - sessionId: string, - options?: { - workspaceId?: string; - activateWorkspace?: (workspaceId: string) => void | Promise; - } -): Promise { - appManager.updateLayout({ - leftPanelActiveTab: 'sessions', - leftPanelCollapsed: false, - }); - - if (options?.workspaceId && options.activateWorkspace) { - await options.activateWorkspace(options.workspaceId); - } - - const isTargetActive = () => flowChatStore.getState().activeSessionId === sessionId; - - if (isTargetActive()) { - syncSessionToModernStore(sessionId); - } else { - await flowChatManager.switchChatSession(sessionId); - if (!isTargetActive()) { - return; - } - syncSessionToModernStore(sessionId); - } - - useSceneStore.getState().openScene('session'); -} - export function openBtwSessionInAuxPane(params: { childSessionId: string; parentSessionId: string; 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 10f265d6ab..2be76d5675 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 @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + archiveChatSession, + deleteChatSession, ensureBackendSession, preloadHistoricalSessionForOpen, retryCreateBackendSession, @@ -24,9 +26,18 @@ const configApiMocks = vi.hoisted(() => ({ getConfig: vi.fn(), })); +const sessionApiMocks = vi.hoisted(() => ({ + archiveSession: vi.fn(), +})); + const persistenceMocks = vi.hoisted(() => ({ touchSessionActivity: vi.fn(), cleanupSaveState: vi.fn(), + cleanupSessionBuffers: vi.fn(), +})); + +const stateMachineMocks = vi.hoisted(() => ({ + delete: vi.fn(), })); vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ @@ -38,7 +49,7 @@ vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ })); vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ - sessionAPI: {}, + sessionAPI: sessionApiMocks, })); vi.mock('../../../shared/notification-system', () => ({ @@ -68,6 +79,14 @@ vi.mock('./PersistenceModule', () => ({ cleanupSaveState: persistenceMocks.cleanupSaveState, })); +vi.mock('./TextChunkModule', () => ({ + cleanupSessionBuffers: persistenceMocks.cleanupSessionBuffers, +})); + +vi.mock('../../state-machine', () => ({ + stateMachineManager: stateMachineMocks, +})); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -103,10 +122,32 @@ function createSession(overrides: Partial = {}): Session { }; } -function createContext(session: Session) { +function createContext( + session: Session, + options?: { + additionalSessions?: Session[]; + activeSessionId?: string | null; + deleteSessionImpl?: ( + sessionId: string, + options?: { nextActiveSessionId?: string | null }, + ) => Promise | void; + removeSessionImpl?: ( + sessionId: string, + options?: { nextActiveSessionId?: string | null }, + ) => string[] | void; + getCascadeSessionIdsImpl?: (sessionId: string) => string[]; + }, +) { + const initialSessions = new Map([ + [session.sessionId, session], + ...((options?.additionalSessions ?? []).map(extra => [extra.sessionId, extra] as const)), + ]); let state = { - sessions: new Map([[session.sessionId, session]]), - activeSessionId: null as string | null, + sessions: initialSessions, + activeSessionId: options?.activeSessionId ?? null as string | null, + }; + const processingManager = { + clearSessionStatus: vi.fn(), }; const flowChatStore = { getState: () => state, @@ -114,6 +155,51 @@ function createContext(session: Session) { state = { ...state, activeSessionId: sessionId }; }), loadSessionHistory: vi.fn(), + getCascadeSessionIds: vi.fn((sessionId: string) => ( + options?.getCascadeSessionIdsImpl?.(sessionId) ?? [sessionId] + )), + deleteSession: vi.fn(async ( + sessionId: string, + deleteOptions?: { nextActiveSessionId?: string | null }, + ) => { + if (options?.deleteSessionImpl) { + await options.deleteSessionImpl(sessionId, deleteOptions); + return; + } + const nextSessions = new Map(state.sessions); + nextSessions.delete(sessionId); + state = { + ...state, + sessions: nextSessions, + activeSessionId: state.activeSessionId === sessionId + ? deleteOptions && 'nextActiveSessionId' in deleteOptions + ? deleteOptions.nextActiveSessionId ?? null + : null + : state.activeSessionId, + }; + }), + removeSession: vi.fn(( + sessionId: string, + removeOptions?: { nextActiveSessionId?: string | null }, + ) => { + if (options?.removeSessionImpl) { + return options.removeSessionImpl(sessionId, removeOptions) ?? [sessionId]; + } + const removedSessionIds = options?.getCascadeSessionIdsImpl?.(sessionId) ?? [sessionId]; + const removedSessionIdSet = new Set(removedSessionIds); + const nextSessions = new Map(state.sessions); + removedSessionIds.forEach(id => nextSessions.delete(id)); + state = { + ...state, + sessions: nextSessions, + activeSessionId: state.activeSessionId && removedSessionIdSet.has(state.activeSessionId) + ? removeOptions && 'nextActiveSessionId' in removeOptions + ? removeOptions.nextActiveSessionId ?? null + : null + : state.activeSessionId, + }; + return removedSessionIds; + }), setState: vi.fn((updater: any) => { state = updater(state); }), @@ -122,10 +208,12 @@ function createContext(session: Session) { return { context: { flowChatStore, + processingManager, pendingHistoryLoads: new Map>(), pendingContextRestores: new Map>(), } as any, flowChatStore, + processingManager, }; } @@ -523,6 +611,150 @@ describe('SessionModule historical session coordination', () => { expect(flowChatStore.loadSessionHistory).not.toHaveBeenCalled(); }); + it('returns to the welcome state after deleting an empty new active session', async () => { + const activeSession = createSession({ + sessionId: 'active-1', + title: 'Current session', + isHistorical: false, + historyState: 'new', + }); + const fallbackSession = createSession({ + sessionId: 'history-2', + title: 'Assistant session', + }); + const { context, flowChatStore, processingManager } = createContext(activeSession, { + additionalSessions: [fallbackSession], + activeSessionId: 'active-1', + deleteSessionImpl: async ( + deletedSessionId: string, + deleteOptions?: { nextActiveSessionId?: string | null }, + ) => { + expect(deletedSessionId).toBe('active-1'); + expect(deleteOptions).toEqual({ nextActiveSessionId: null }); + flowChatStore.setState((prev: any) => { + const nextSessions = new Map(prev.sessions); + nextSessions.delete(deletedSessionId); + return { + ...prev, + sessions: nextSessions, + activeSessionId: deleteOptions && 'nextActiveSessionId' in deleteOptions + ? deleteOptions.nextActiveSessionId ?? null + : 'history-2', + }; + }); + }, + }); + persistenceMocks.touchSessionActivity.mockResolvedValueOnce(undefined); + + const deleting = deleteChatSession(context, 'active-1'); + await Promise.resolve(); + await Promise.resolve(); + + expect(flowChatStore.deleteSession).toHaveBeenCalledWith( + 'active-1', + { nextActiveSessionId: null }, + ); + await deleting; + + expect(flowChatStore.loadSessionHistory).not.toHaveBeenCalled(); + expect(flowChatStore.getState().activeSessionId).toBeNull(); + expect(processingManager.clearSessionStatus).toHaveBeenCalledWith('active-1'); + expect(persistenceMocks.cleanupSaveState).toHaveBeenCalledWith(context, 'active-1'); + }); + + it('returns to the welcome state after deleting a non-empty active session', async () => { + const activeSession = createSession({ + sessionId: 'active-1', + title: 'Current session', + isHistorical: false, + historyState: 'ready', + dialogTurns: [{ + id: 'turn-1', + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + status: 'completed', + } as any], + }); + const fallbackSession = createSession({ + sessionId: 'history-2', + title: 'Assistant session', + }); + const { context, flowChatStore, processingManager } = createContext(activeSession, { + additionalSessions: [fallbackSession], + activeSessionId: 'active-1', + deleteSessionImpl: async ( + deletedSessionId: string, + deleteOptions?: { nextActiveSessionId?: string | null }, + ) => { + expect(deletedSessionId).toBe('active-1'); + expect(deleteOptions).toEqual({ nextActiveSessionId: null }); + flowChatStore.setState((prev: any) => { + const nextSessions = new Map(prev.sessions); + nextSessions.delete(deletedSessionId); + return { + ...prev, + sessions: nextSessions, + activeSessionId: deleteOptions && 'nextActiveSessionId' in deleteOptions + ? deleteOptions.nextActiveSessionId ?? null + : 'history-2', + }; + }); + }, + }); + persistenceMocks.touchSessionActivity.mockResolvedValueOnce(undefined); + + const deleting = deleteChatSession(context, 'active-1'); + await Promise.resolve(); + + expect(flowChatStore.deleteSession).toHaveBeenCalledWith( + 'active-1', + { nextActiveSessionId: null }, + ); + await deleting; + + expect(flowChatStore.loadSessionHistory).not.toHaveBeenCalled(); + expect(flowChatStore.switchSession).not.toHaveBeenCalled(); + expect(flowChatStore.getState().activeSessionId).toBeNull(); + expect(processingManager.clearSessionStatus).toHaveBeenCalledWith('active-1'); + expect(persistenceMocks.cleanupSaveState).toHaveBeenCalledWith(context, 'active-1'); + }); + + it('returns to the welcome state after archiving an active session', async () => { + const activeSession = createSession({ + sessionId: 'active-1', + title: 'Current session', + isHistorical: false, + historyState: 'ready', + }); + const fallbackSession = createSession({ + sessionId: 'history-2', + title: 'Assistant session', + }); + const { context, flowChatStore, processingManager } = createContext(activeSession, { + additionalSessions: [fallbackSession], + activeSessionId: 'active-1', + }); + sessionApiMocks.archiveSession.mockResolvedValueOnce(undefined); + + await archiveChatSession(context, 'active-1'); + + expect(sessionApiMocks.archiveSession).toHaveBeenCalledWith( + 'active-1', + 'D:/workspace/BitFun', + undefined, + undefined, + ); + expect(flowChatStore.removeSession).toHaveBeenCalledWith( + 'active-1', + { nextActiveSessionId: null }, + ); + expect(flowChatStore.getState().activeSessionId).toBeNull(); + expect(stateMachineMocks.delete).toHaveBeenCalledWith('active-1'); + expect(processingManager.clearSessionStatus).toHaveBeenCalledWith('active-1'); + expect(persistenceMocks.cleanupSaveState).toHaveBeenCalledWith(context, 'active-1'); + expect(persistenceMocks.cleanupSessionBuffers).toHaveBeenCalledWith(context, 'active-1'); + }); + it('reuses pending historical hydration before ensuring the backend session', async () => { const pendingHydrate = 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 fb46993d0f..b324177afb 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 @@ -18,6 +18,7 @@ import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config import type { FlowChatContext, SessionConfig } from './types'; import type { Session } from '../../types/flow-chat'; import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; +import { cleanupSessionBuffers } from './TextChunkModule'; import { createTextSessionTitleDescriptor, createDefaultSessionTitleDescriptor, @@ -747,8 +748,18 @@ export async function deleteChatSession( sessionId: string ): Promise { try { + const stateBeforeDelete = context.flowChatStore.getState(); const removedSessionIds = context.flowChatStore.getCascadeSessionIds(sessionId); - await context.flowChatStore.deleteSession(sessionId); + const removedSessionIdSet = new Set(removedSessionIds); + const removedActiveSession = Boolean( + stateBeforeDelete.activeSessionId + && removedSessionIdSet.has(stateBeforeDelete.activeSessionId) + ); + await context.flowChatStore.deleteSession( + sessionId, + removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + removedSessionIds.forEach(id => { context.processingManager.clearSessionStatus(id); cleanupSaveState(context, id); @@ -762,6 +773,52 @@ export async function deleteChatSession( } } +export async function archiveChatSession( + context: FlowChatContext, + sessionId: string +): Promise { + try { + const stateBeforeArchive = context.flowChatStore.getState(); + const session = stateBeforeArchive.sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + + const removedSessionIds = context.flowChatStore.getCascadeSessionIds(sessionId); + const removedSessionIdSet = new Set(removedSessionIds); + const removedActiveSession = Boolean( + stateBeforeArchive.activeSessionId + && removedSessionIdSet.has(stateBeforeArchive.activeSessionId) + ); + + await sessionAPI.archiveSession( + sessionId, + requireSessionWorkspacePath(session.workspacePath, sessionId), + session.remoteConnectionId, + session.remoteSshHost, + ); + + const { stateMachineManager } = await import('../../state-machine'); + context.flowChatStore.removeSession( + sessionId, + removedActiveSession ? { nextActiveSessionId: null } : undefined, + ); + + removedSessionIds.forEach(id => { + stateMachineManager.delete(id); + context.processingManager.clearSessionStatus(id); + cleanupSaveState(context, id); + cleanupSessionBuffers(context, id); + }); + } catch (error) { + log.error('Failed to archive chat session', { sessionId, error }); + notificationService.error('Failed to archive session', { + duration: 3000 + }); + throw error; + } +} + export async function renameChatSessionTitle( context: FlowChatContext, sessionId: string, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index c97d603547..8c7b22d364 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -35,6 +35,7 @@ export { preloadHistoricalSessionForOpen, switchChatSession, deleteChatSession, + archiveChatSession, renameChatSessionTitle, forkChatSession, } from './SessionModule'; diff --git a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts index 74b0aa645c..2d08bd1dc5 100644 --- a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts +++ b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ensureBtwSessionAvailable, openBtwSessionInAuxPane, openMainSession } from './openBtwSession'; +import { ensureBtwSessionAvailable, openBtwSessionInAuxPane } from './btwSessionPane'; +import { openMainSession } from './sessionActivation'; const mocks = vi.hoisted(() => ({ createTab: vi.fn(), @@ -322,4 +323,36 @@ describe('openMainSession', () => { expect(mocks.syncSessionToModernStore).not.toHaveBeenCalledWith('session-b'); expect(mocks.openScene).not.toHaveBeenCalledWith('session'); }); + + it('rehydrates an already-active metadata-only historical session before syncing it', async () => { + sessions.set('session-b', { + sessionId: 'session-b', + isHistorical: true, + historyState: 'metadata-only', + dialogTurns: [], + }); + activeSessionId = 'session-b'; + + await openMainSession('session-b'); + + expect(mocks.switchChatSession).toHaveBeenCalledWith('session-b'); + expect(mocks.syncSessionToModernStore).toHaveBeenCalledWith('session-b'); + expect(mocks.openScene).toHaveBeenCalledWith('session'); + }); + + it('does not re-switch an already-active ready session', async () => { + sessions.set('session-b', { + sessionId: 'session-b', + isHistorical: false, + historyState: 'ready', + dialogTurns: [{ id: 'turn-1' }], + }); + activeSessionId = 'session-b'; + + await openMainSession('session-b'); + + expect(mocks.switchChatSession).not.toHaveBeenCalled(); + expect(mocks.syncSessionToModernStore).toHaveBeenCalledWith('session-b'); + expect(mocks.openScene).toHaveBeenCalledWith('session'); + }); }); diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts new file mode 100644 index 0000000000..dec4cb42e2 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -0,0 +1,59 @@ +import { appManager } from '@/app/services/AppManager'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { flowChatStore } from '../store/FlowChatStore'; +import { flowChatManager } from './FlowChatManager'; +import { syncSessionToModernStore } from './storeSync'; + +export async function openMainSession( + sessionId: string, + options?: { + workspaceId?: string; + activateWorkspace?: (workspaceId: string) => void | Promise; + } +): Promise { + if (options?.workspaceId && options.activateWorkspace) { + await options.activateWorkspace(options.workspaceId); + } + + appManager.updateLayout({ + leftPanelActiveTab: 'sessions', + leftPanelCollapsed: false, + }); + + const activated = await activateMainSession(sessionId); + if (!activated) { + return; + } + + useSceneStore.getState().openScene('session'); +} + +export async function activateMainSession(sessionId: string): Promise { + const isTargetActive = () => flowChatStore.getState().activeSessionId === sessionId; + const targetSession = flowChatStore.getState().sessions.get(sessionId) ?? null; + if (!targetSession) { + return false; + } + + if (isTargetActive()) { + const activeSession = flowChatStore.getState().sessions.get(sessionId) ?? null; + if ( + activeSession?.isHistorical && + (activeSession.historyState === 'metadata-only' || activeSession.historyState === 'failed') + ) { + await flowChatManager.switchChatSession(sessionId); + if (!isTargetActive()) { + return false; + } + } + syncSessionToModernStore(sessionId); + } else { + await flowChatManager.switchChatSession(sessionId); + if (!isTargetActive()) { + return false; + } + syncSessionToModernStore(sessionId); + } + + return true; +} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 391f6192df..e9848e5294 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -8,6 +8,7 @@ const apiMocks = vi.hoisted(() => ({ listSessionsPage: vi.fn(), loadSessionTurns: vi.fn(), saveSessionTurn: vi.fn(), + deleteSession: vi.fn(), restoreSession: vi.fn(), restoreSessionView: vi.fn(), restoreSessionWithTurns: vi.fn(), @@ -32,6 +33,7 @@ const configManagerMock = vi.hoisted(() => { }); const stateMachineManagerMock = vi.hoisted(() => ({ + delete: vi.fn(), getOrCreate: vi.fn(), reset: vi.fn(), })); @@ -56,6 +58,7 @@ vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { + deleteSession: apiMocks.deleteSession, restoreSession: apiMocks.restoreSession, get restoreSessionView() { return apiMocks.restoreSessionView; @@ -101,6 +104,7 @@ const resetStore = () => { ((flowChatStore as any).deferredFullHistoryProjections as Map | undefined)?.clear(); ((flowChatStore as any).fullHistoryProjectionApplyRequests as Set | undefined)?.clear(); ((flowChatStore as any).unsupportedRestoreCommands as Set | undefined)?.clear(); + ((flowChatStore as any).pendingRemoveSessionOptions as Map | undefined)?.clear(); flowChatStore.setState((): FlowChatState => ({ sessions: new Map(), activeSessionId: null, @@ -197,6 +201,77 @@ describe('FlowChatStore metadata persistence callbacks', () => { }); }); +describe('FlowChatStore session removal active selection', () => { + afterEach(() => { + resetStore(); + }); + + it('can clear the active session atomically while keeping other sessions', () => { + const keepSession = createSession({ + sessionId: 'session-keep', + title: 'Keep me', + }); + const removeSession = createSession({ + sessionId: 'session-remove', + title: 'Remove me', + }); + + flowChatStore.setState(() => ({ + sessions: new Map([ + [keepSession.sessionId, keepSession], + [removeSession.sessionId, removeSession], + ]), + activeSessionId: removeSession.sessionId, + })); + + const removedSessionIds = flowChatStore.removeSession(removeSession.sessionId, { + nextActiveSessionId: null, + }); + + expect(removedSessionIds).toEqual(['session-remove']); + expect(flowChatStore.getState().activeSessionId).toBeNull(); + expect(Array.from(flowChatStore.getState().sessions.keys())).toEqual(['session-keep']); + }); + + it('reuses pending delete intent when a concurrent local remove wins the race', async () => { + const deleteDeferred = createDeferred(); + apiMocks.deleteSession.mockImplementation(() => deleteDeferred.promise); + const keepSession = createSession({ + sessionId: 'session-keep', + title: 'Keep me', + workspacePath: 'D:/workspace/BitFun', + }); + const removeSession = createSession({ + sessionId: 'session-remove', + title: 'Remove me', + workspacePath: 'D:/workspace/BitFun', + }); + + flowChatStore.setState(() => ({ + sessions: new Map([ + [keepSession.sessionId, keepSession], + [removeSession.sessionId, removeSession], + ]), + activeSessionId: removeSession.sessionId, + })); + + const deleting = flowChatStore.deleteSession(removeSession.sessionId, { + nextActiveSessionId: null, + }); + await flushAsyncWork(); + + const removedSessionIds = flowChatStore.removeSession(removeSession.sessionId); + expect(removedSessionIds).toEqual(['session-remove']); + expect(flowChatStore.getState().activeSessionId).toBeNull(); + + deleteDeferred.resolve(); + await deleting; + + expect(flowChatStore.getState().activeSessionId).toBeNull(); + expect(Array.from(flowChatStore.getState().sessions.keys())).toEqual(['session-keep']); + }); +}); + describe('FlowChatStore token usage', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 5e8b60e1a7..71d18e5ea4 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -78,6 +78,10 @@ const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; const HISTORICAL_SESSION_INITIAL_REMOTE_TAIL_TURN_COUNT = 3; const HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT = 3; const HISTORICAL_SESSION_FULL_HISTORY_IDLE_TIMEOUT_MS = 1500; + +type RemoveSessionOptions = { + nextActiveSessionId?: string | null; +}; const HISTORICAL_SESSION_FULL_HISTORY_FIRST_PAINT_TIMEOUT_MS = 2500; const MAX_DEFERRED_FULL_HISTORY_PROJECTIONS = 3; @@ -560,6 +564,7 @@ export class FlowChatStore { private deferredFullHistoryProjections = new Map(); private fullHistoryProjectionApplyRequests = new Set(); private unsupportedRestoreCommands = new Set(); + private pendingRemoveSessionOptions = new Map(); private onPersistUnreadCompletion?: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined) => void; private constructor() { @@ -1774,11 +1779,14 @@ export class FlowChatStore { }); } - public async deleteSession(sessionId: string): Promise { + public async deleteSession(sessionId: string, options?: RemoveSessionOptions): Promise { const sessionIdsToDelete = this.getCascadeSessionIds(sessionId); if (sessionIdsToDelete.length === 0) { return; } + if (options) { + this.pendingRemoveSessionOptions.set(sessionId, options); + } const { stateMachineManager } = await import('../state-machine'); sessionIdsToDelete.forEach(id => { @@ -1816,14 +1824,18 @@ export class FlowChatStore { log.error('Failed to delete session on backend', { sessionId, error }); } - this.removeSession(sessionId); + this.removeSession(sessionId, options); + this.pendingRemoveSessionOptions.delete(sessionId); } - public removeSession(sessionId: string): string[] { + public removeSession(sessionId: string, options?: RemoveSessionOptions): string[] { const removedSessionIds = this.getCascadeSessionIds(sessionId); if (removedSessionIds.length === 0) { + this.pendingRemoveSessionOptions.delete(sessionId); return []; } + const resolvedOptions = options ?? this.pendingRemoveSessionOptions.get(sessionId); + this.pendingRemoveSessionOptions.delete(sessionId); this.clearRemovedSessionHistoryState(removedSessionIds, 'session-removed'); useBackgroundSubagentActivityStore.getState().removeSessions(removedSessionIds); @@ -1872,8 +1884,12 @@ export class FlowChatStore { let newActiveSessionId = prev.activeSessionId; if (prev.activeSessionId && removedSessionIdSet.has(prev.activeSessionId)) { - const remainingSessions = Array.from(newSessions.keys()); - newActiveSessionId = remainingSessions.length > 0 ? remainingSessions[0] : null; + if (resolvedOptions && 'nextActiveSessionId' in resolvedOptions) { + newActiveSessionId = resolvedOptions.nextActiveSessionId ?? null; + } else { + const remainingSessions = Array.from(newSessions.keys()); + newActiveSessionId = remainingSessions.length > 0 ? remainingSessions[0] : null; + } } return { diff --git a/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.tsx index ec9b3b0eca..e6c3960dd0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReviewSessionSummaryCard.tsx @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; import { flowChatStore } from '../store/FlowChatStore'; -import { openBtwSessionInAuxPane, openMainSession } from '../services/openBtwSession'; +import { openBtwSessionInAuxPane } from '../services/btwSessionPane'; +import { openMainSession } from '../services/sessionActivation'; import { snapshotAPI } from '@/infrastructure/api'; import { collectReviewChangedFiles, diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx index 185aaa2320..45a6a06fe5 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx @@ -43,7 +43,7 @@ vi.mock('./ToolTimeoutIndicator', () => ({ ToolTimeoutIndicator: () => , })); -vi.mock('../services/openBtwSession', () => ({ +vi.mock('../services/btwSessionPane', () => ({ openBtwSessionInAuxPane: (...args: unknown[]) => mocks.openBtwSessionInAuxPane(...args), })); diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx index 8252cfebfe..6e7e7ac20b 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx @@ -23,7 +23,7 @@ import { getReviewerContextBySubagentId } from '@/shared/services/reviewTeamServ import type { ReviewerContext } from '@/shared/services/reviewTeamService'; import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; import { AcpPermissionActions } from './AcpPermissionActions'; -import { openBtwSessionInAuxPane } from '../services/openBtwSession'; +import { openBtwSessionInAuxPane } from '../services/btwSessionPane'; import { flowChatStore } from '../store/FlowChatStore'; import { useSessionGoalModeActive } from '../hooks/useSessionGoalModeActive'; import './TaskToolDisplay.scss';