From 93ba5d8c683788bea3fcd494ab4378a4520ac82d Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 10:14:35 +0800 Subject: [PATCH 1/3] fix(web-ui): remember the session right pane state per session Switching sessions reused the global layout state, so a session opened its right pane only because the previously active session had it open. Record the pane state of the session being left and apply the state of the session being entered, falling back to the collapsed default for a session that was never left before. The memory is in-memory only, and the existing idempotent writers keep editor mode, where this pane is the main content surface, untouched. --- .../session/sessionAuxPaneMemory.test.ts | 106 ++++++++++++++++++ .../scenes/session/sessionAuxPaneMemory.ts | 54 +++++++++ .../src/app/services/sessionSceneLifecycle.ts | 3 + 3 files changed, 163 insertions(+) create mode 100644 src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts create mode 100644 src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts diff --git a/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts new file mode 100644 index 0000000000..1a5c2b9490 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { appManager } from '../../services/AppManager'; +import { clearSessionAuxPaneMemory, startSessionAuxPaneMemory } from './sessionAuxPaneMemory'; + +function session(sessionId: string): Session { + return { + sessionId, + title: sessionId, + dialogTurns: [], + status: 'idle', + config: {}, + createdAt: 1, + lastActiveAt: 1, + error: null, + }; +} + +function seed(sessionIds: string[]): void { + flowChatStore.setState(previous => ({ + ...previous, + sessions: new Map(sessionIds.map(id => [id, session(id)])), + activeSessionId: null, + })); +} + +const collapsed = () => appManager.getState().layout.rightPanelCollapsed; +const setCollapsed = (value: boolean) => appManager.updateLayout({ rightPanelCollapsed: value }); + +describe('session aux pane memory', () => { + let stop: (() => void) | undefined; + + beforeEach(() => { + clearSessionAuxPaneMemory(); + appManager.updateLayout({ chatCollapsed: false, rightPanelCollapsed: true }); + seed(['a', 'b']); + }); + + afterEach(() => { + stop?.(); + stop = undefined; + }); + + it('restores a remembered session and uses the collapsed default for an unseen one', () => { + stop = startSessionAuxPaneMemory(); + + flowChatStore.switchSession('a'); + setCollapsed(false); + flowChatStore.switchSession('b'); + expect(collapsed()).toBe(true); + flowChatStore.switchSession('a'); + expect(collapsed()).toBe(false); + }); + + it('keeps an independent state per session', () => { + stop = startSessionAuxPaneMemory(); + + flowChatStore.switchSession('a'); + setCollapsed(false); + flowChatStore.switchSession('b'); + setCollapsed(false); + flowChatStore.switchSession('a'); + setCollapsed(true); + expect(collapsed()).toBe(true); + + flowChatStore.switchSession('b'); + expect(collapsed()).toBe(false); + flowChatStore.switchSession('a'); + expect(collapsed()).toBe(true); + }); + + it('leaves editor mode, where this pane is the main surface, untouched', () => { + stop = startSessionAuxPaneMemory(); + + flowChatStore.switchSession('a'); + appManager.updateLayout({ chatCollapsed: true, rightPanelCollapsed: false }); + flowChatStore.switchSession('b'); + + expect(collapsed()).toBe(false); + expect(appManager.getState().layout.chatCollapsed).toBe(true); + }); + + it('does not attribute an active session when there is none', () => { + stop = startSessionAuxPaneMemory(); + + setCollapsed(false); + flowChatStore.switchSession('a'); + + expect(collapsed()).toBe(true); + }); + + it('stops tracking sessions after the shell unmounts', () => { + stop = startSessionAuxPaneMemory(); + flowChatStore.switchSession('a'); + setCollapsed(false); + stop(); + stop = undefined; + + flowChatStore.switchSession('b'); + + expect(collapsed()).toBe(false); + }); +}); diff --git a/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts new file mode 100644 index 0000000000..682222b157 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/sessionAuxPaneMemory.ts @@ -0,0 +1,54 @@ +/** + * Session-scoped memory for the session scene right pane (AuxPane). + * + * The layout itself stays global (`appManager.layout.rightPanelCollapsed`); this + * module is the only place that maps the active session to the state it was + * left with, so switching sessions restores the target session instead of + * inheriting the state of the session that was active before. + */ + +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { appManager } from '../../services/AppManager'; +import { collapseSessionAuxPane, expandSessionAuxPane } from './sessionPanelLayout'; + +/** Sessions that were never left before fall back to the collapsed default. */ +const UNSEEN_SESSION_COLLAPSED = true; + +/** In-memory only: a fresh start uses the app default for every session. */ +const collapsedBySession = new Map(); + +export function clearSessionAuxPaneMemory(): void { + collapsedBySession.clear(); +} + +/** + * Remembers the pane state of the session being left and applies the state of + * the session being entered. Returns the unsubscribe function for the shell. + */ +export function startSessionAuxPaneMemory(): () => void { + let previousSessionId = flowChatStore.getState().activeSessionId; + + return flowChatStore.subscribe(state => { + const nextSessionId = state.activeSessionId; + if (nextSessionId === previousSessionId) return; + + const leftSessionId = previousSessionId; + previousSessionId = nextSessionId; + + if (leftSessionId) { + collapsedBySession.set( + leftSessionId, + appManager.getState().layout.rightPanelCollapsed, + ); + } + if (!nextSessionId) return; + + // Both writers are idempotent and keep editor mode, where this pane is the + // main content surface, untouched. + if (collapsedBySession.get(nextSessionId) ?? UNSEEN_SESSION_COLLAPSED) { + collapseSessionAuxPane(); + } else { + expandSessionAuxPane(); + } + }); +} diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index c07e9be6ce..59850878ca 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -5,6 +5,7 @@ import { getActiveSurfaceId, isSurfaceChangedError } from '@/infrastructure/peer import { createLogger } from '@/shared/utils/logger'; import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; import { isSessionSceneId, type SessionSceneTarget } from '../components/SceneBar/types'; +import { startSessionAuxPaneMemory } from '../scenes/session/sessionAuxPaneMemory'; import { resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; const log = createLogger('SessionSceneLifecycle'); @@ -15,6 +16,7 @@ const log = createLogger('SessionSceneLifecycle'); */ export function startSessionSceneLifecycle(): () => void { const stopProjectionSync = startAutoSync(); + const stopAuxPaneMemory = startSessionAuxPaneMemory(); const current = () => { const session = flowChatStore.getActiveSession(); return session ? resolveSessionSceneTarget( @@ -112,6 +114,7 @@ export function startSessionSceneLifecycle(): () => void { unsubscribeScenes(); unsubscribeWorkspaces(); stopNavigation(); + stopAuxPaneMemory(); stopProjectionSync(); }; } From c90df5395f1788629ccd635a81db871d92223ce6 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 10:48:36 +0800 Subject: [PATCH 2/3] fix(web-ui): scope the session right pane to its session The agent canvas was keyed by the active workspace, so sessions in one workspace shared a single set of tabs and a worktree could not keep its sessions apart. Key it by the active session instead, swap it synchronously from the FlowChat selection, and open content against the target session's scope. A restored snapshot is not a content change: the pane coordinator no longer re-derives the host's open state from it (the session pane memory owns that state per session), and the BTW parent-session sync no longer replays a navigation that would pull the user back to the session which left the tab. --- .../app/components/SceneBar/SceneBar.test.tsx | 6 +- .../panels/content-canvas/ContentCanvas.tsx | 14 ++ .../hooks/canvasPanelOwnership.test.tsx | 37 ++--- .../hooks/usePanelTabCoordinator.ts | 6 + .../content-canvas/stores/canvasStore.ts | 61 ++++----- .../panels/content-canvas/stores/index.ts | 3 +- .../session/AuxPane.session-switch.test.tsx | 121 ++++++++++++++++ src/web-ui/src/app/scenes/session/AuxPane.tsx | 51 +++---- .../session/AuxPane.workspace-switch.test.tsx | 129 ------------------ .../app/workbench/workbenchContent.test.ts | 28 ++-- .../services/workbenchContentService.ts | 10 +- 11 files changed, 224 insertions(+), 242 deletions(-) create mode 100644 src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx delete mode 100644 src/web-ui/src/app/scenes/session/AuxPane.workspace-switch.test.tsx diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx index aebe666f31..40ab230a3c 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx @@ -11,7 +11,7 @@ import SceneBar from './SceneBar'; import { useSceneStore } from '../../stores/sceneStore'; import { useContentResourceStore } from '../../workbench/contentResourceStore'; import { writeSessionTabDrag } from '../../workbench/canvasTabTransfer'; -import { clearAgentCanvasForPeerSwitch, switchAgentCanvasWorkspace, useAgentCanvasStore } from '../panels/content-canvas/stores'; +import { clearAgentCanvasForPeerSwitch, switchAgentCanvasScope, useAgentCanvasStore } from '../panels/content-canvas/stores'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -170,7 +170,7 @@ describe('SceneBar overflow navigation', () => { elsewhere.remove(); }); - it.each(['end', 'remove', 'workspace-switch', 'device-switch'])('withdraws availability when the source is invalidated: %s', action => { + it.each(['end', 'remove', 'scope-switch', 'device-switch'])('withdraws availability when the source is invalidated: %s', action => { renderSceneBar(); act(() => { startSessionTabDrag(); }); expect(container.querySelector('[data-openbitfun-part="dropHint"]')).not.toBeNull(); @@ -178,7 +178,7 @@ describe('SceneBar overflow navigation', () => { const store = useAgentCanvasStore.getState(); if (action === 'end') store.endDrag(); if (action === 'remove') store.detachTab(store.draggingTabId!, 'primary'); - if (action === 'workspace-switch') switchAgentCanvasWorkspace(undefined, 'another-workspace'); + if (action === 'scope-switch') switchAgentCanvasScope('another-session'); if (action === 'device-switch') activateSurface('peer'); }); expect(container.querySelector('[data-openbitfun-part="dropHint"]')).toBeNull(); 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 1ba02ece3b..c6d5d3e035 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 @@ -71,6 +71,7 @@ export const ContentCanvas: React.FC = ({ const openMissionControl = useCanvasStore(state => state.openMissionControl); const activeBtwSessionTab = useCanvasStore(state => selectActiveBtwSessionTab(state as any)); const activeBtwSessionData = activeBtwSessionTab?.content.data as BtwSessionPanelData | undefined; + const canvasScopeKey = useCanvasStore(state => state.scopeKey); const { workspace: currentWorkspace } = useCurrentWorkspace(); const activeSessionId = useSyncExternalStore( flowChatStore.subscribe.bind(flowChatStore), @@ -79,6 +80,7 @@ export const ContentCanvas: React.FC = ({ ); const currentWorkspaceId = currentWorkspace?.id; const lastSyncedBtwTabIdRef = useRef(null); + const lastCanvasScopeKeyRef = useRef(canvasScopeKey); // Initialize hooks const { handleCloseWithDirtyCheck, handleCloseAllWithDirtyCheck } = useTabLifecycle({ mode, @@ -93,6 +95,9 @@ export const ContentCanvas: React.FC = ({ }); useEffect(() => { + const canvasScopeChanged = lastCanvasScopeKeyRef.current !== canvasScopeKey; + lastCanvasScopeKeyRef.current = canvasScopeKey; + if (mode !== 'agent' || !activeBtwSessionTab?.id || !activeBtwSessionData?.parentSessionId) { lastSyncedBtwTabIdRef.current = null; return; @@ -102,6 +107,14 @@ export const ContentCanvas: React.FC = ({ return; } + // Restoring another session's canvas is not a request to reopen the tabs it + // had. Navigating there would pull the user away from the session they just + // switched to, so the restored tab only loses its "unsynced" state. + if (canvasScopeChanged) { + lastSyncedBtwTabIdRef.current = activeBtwSessionTab.id; + return; + } + // Only sync when the BTW session belongs to the current workspace, // preventing the wrong session from opening when switching workspaces. // Workspace ID is the identity; the path check only serves tabs restored @@ -127,6 +140,7 @@ export const ContentCanvas: React.FC = ({ activeBtwSessionData?.workspaceId, activeBtwSessionData?.workspacePath, activeBtwSessionTab?.id, + canvasScopeKey, currentWorkspaceId, mode, workspacePath, diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx index 1088faa5bc..aa276f6bbb 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CanvasStoreModeContext, clearAgentCanvasForPeerSwitch, - switchAgentCanvasWorkspace, + switchAgentCanvasScope, useAgentCanvasStore, useBottomTerminalCanvasStore, useCanvasStore, @@ -56,7 +56,7 @@ function CanvasProbe({ mode, onReveal }: { mode: CanvasStoreMode; onReveal?: () const expandBottom = () => expandSessionBottomTerminalPane(240); function PanelProbe({ bottom = false }: { bottom?: boolean }) { - const scopeKey = useCanvasStore(state => state.workspaceKey); + const scopeKey = useCanvasStore(state => state.scopeKey); const visibleTabCount = useCanvasStore(state => ( [state.primaryGroup, state.secondaryGroup, state.tertiaryGroup] .reduce((count, group) => count + group.tabs.filter(tab => !tab.isHidden).length, 0) @@ -206,26 +206,28 @@ describe('canvas host panel ownership', () => { expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); }); - it('restores panel visibility when workspace content returns', async () => { - switchAgentCanvasWorkspace(null, 'workspace-a'); + it('restores a scope snapshot without opening the panel for the host', async () => { + switchAgentCanvasScope('session-a'); useAgentCanvasStore.getState().addTab(content('session'), 'active'); await act(async () => root.render()); const tab = useAgentCanvasStore.getState().primaryGroup.tabs[0]; await act(async () => useAgentCanvasStore.getState().updateTabContent(tab.id, 'primary', content('updated'))); expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); await act(async () => { - switchAgentCanvasWorkspace('workspace-a', 'workspace-b'); + switchAgentCanvasScope('session-b'); root.render(); }); await act(async () => { - switchAgentCanvasWorkspace('workspace-b', 'workspace-a'); + switchAgentCanvasScope('session-a'); root.render(); }); + // The entered scope owns its own open state; content returning with a scope + // swap must not reopen a panel the host restored as collapsed. expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(1); - expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); + expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); await act(async () => root.render(null)); await act(async () => root.render()); - expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); + expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); }); it('keeps an explicitly opened empty panel and counts all editor groups when closing', async () => { @@ -245,18 +247,19 @@ describe('canvas host panel ownership', () => { expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); }); - it('collapses when a workspace snapshot has no visible content before the shell rerenders', async () => { - switchAgentCanvasWorkspace(null, 'workspace-a'); - useAgentCanvasStore.getState().addTab(content('workspace-a'), 'active'); + it('keeps canvas content per scope instead of sharing it between scopes', async () => { + switchAgentCanvasScope('session-a'); + useAgentCanvasStore.getState().addTab(content('session-a'), 'active'); appManager.updateLayout({ rightPanelCollapsed: false }); await act(async () => root.render()); - await act(async () => switchAgentCanvasWorkspace('workspace-a', 'workspace-b')); - expect(useAgentCanvasStore.getState().workspaceKey).toBe('workspace-b'); + await act(async () => switchAgentCanvasScope('session-b')); + expect(useAgentCanvasStore.getState().scopeKey).toBe('session-b'); expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(0); - expect(appManager.getState().layout.rightPanelCollapsed).toBe(true); - await act(async () => switchAgentCanvasWorkspace('workspace-b', 'workspace-a')); - expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(1); - expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); + await act(async () => useAgentCanvasStore.getState().addTab(content('session-b'), 'active')); + await act(async () => switchAgentCanvasScope('session-a')); + expect(useAgentCanvasStore.getState().primaryGroup.tabs.map(tab => tab.content.title)).toEqual(['session-a']); + await act(async () => switchAgentCanvasScope('session-b')); + expect(useAgentCanvasStore.getState().primaryGroup.tabs.map(tab => tab.content.title)).toEqual(['session-b']); }); it('keeps Git and bottom terminal operations scoped to their hosts', async () => { diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts index 6524c92a7f..0cb61e3e27 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts @@ -25,6 +25,12 @@ export function usePanelTabCoordinator({ useEffect(() => { const previous = previousRef.current; previousRef.current = { visibleTabCount, scopeKey }; + // A scope change restores the content of the scope being entered. That + // scope's open state is owned by the host, which restores it together with + // the content, so a restore is not a content transition here. + if (previous.scopeKey !== scopeKey) { + return; + } if ( previous.visibleTabCount > 0 && visibleTabCount === 0 diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts index 71f6e18be3..9f5ab24797 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts @@ -30,8 +30,8 @@ import { normalizePath } from '@/shared/utils/pathUtils'; // ==================== Store State Types ==================== interface CanvasStoreState { - /** Live content scope, committed with workspace swaps and excluded from snapshots. */ - workspaceKey?: string; + /** Live content scope, committed with scope swaps and excluded from snapshots. */ + scopeKey?: string; primaryGroup: EditorGroupState; secondaryGroup: EditorGroupState; tertiaryGroup: EditorGroupState; @@ -159,7 +159,7 @@ type CanvasStore = CanvasStoreState & CanvasStoreActions; // ==================== Initial State ==================== const initialState: CanvasStoreState = { - workspaceKey: undefined, + scopeKey: undefined, primaryGroup: createEditorGroupState(), secondaryGroup: createEditorGroupState(), tertiaryGroup: createEditorGroupState(), @@ -1169,15 +1169,15 @@ export const useAgentCanvasStore = createCanvasStoreHook(); export const useGitCanvasStore = createCanvasStoreHook(); export const useBottomTerminalCanvasStore = createCanvasStoreHook(); -// ==================== Agent canvas: per-workspace snapshots (AuxPane / Session scene) ==================== -// Switching active workspace saves the current agent canvas under the previous workspace id and restores -// the snapshot for the next id, so remote/local tabs coexist across workspace switches. +// ==================== Agent canvas: per-scope snapshots (AuxPane / Session scene) ==================== +// The agent canvas is scoped to the active session. Leaving a session saves its canvas under that +// session id and entering another one restores its own tabs, so two sessions never share content. -const AGENT_CANVAS_SNAPSHOT_MAX = 12; -const agentWorkspaceSnapshots = new Map(); +const AGENT_CANVAS_SNAPSHOT_MAX = 24; +const agentScopeSnapshots = new Map(); const agentSnapshotLruOrder: string[] = []; -function normalizeAgentWorkspaceKey(id: string | null | undefined): string { +function normalizeAgentScopeKey(id: string | null | undefined): string { return id ?? '__none__'; } @@ -1200,20 +1200,20 @@ function rememberAgentSnapshot(key: string, snapshot: CanvasStoreState): void { const clone = structuredClone(snapshot); clone.draggingTabId = null; clone.draggingFromGroupId = null; - agentWorkspaceSnapshots.set(key, clone); + agentScopeSnapshots.set(key, clone); const idx = agentSnapshotLruOrder.indexOf(key); if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1); agentSnapshotLruOrder.push(key); - while (agentWorkspaceSnapshots.size > AGENT_CANVAS_SNAPSHOT_MAX) { + while (agentScopeSnapshots.size > AGENT_CANVAS_SNAPSHOT_MAX) { const evict = agentSnapshotLruOrder.shift(); if (!evict) break; - agentWorkspaceSnapshots.delete(evict); + agentScopeSnapshots.delete(evict); } } -function applyEmptyAgentCanvas(workspaceKey?: string): void { +function applyEmptyAgentCanvas(scopeKey?: string): void { useAgentCanvasStore.setState({ - workspaceKey, + scopeKey, primaryGroup: createEditorGroupState(), secondaryGroup: createEditorGroupState(), tertiaryGroup: createEditorGroupState(), @@ -1227,9 +1227,9 @@ function applyEmptyAgentCanvas(workspaceKey?: string): void { }); } -/** Clear agent canvas workspace snapshots when entering/exiting Peer Device Mode. */ +/** Clear agent canvas snapshots when entering/exiting Peer Device Mode. */ export function clearAgentCanvasForPeerSwitch(): void { - agentWorkspaceSnapshots.clear(); + agentScopeSnapshots.clear(); agentSnapshotLruOrder.length = 0; applyEmptyAgentCanvas(); useGitCanvasStore.getState().reset(); @@ -1237,19 +1237,16 @@ export function clearAgentCanvasForPeerSwitch(): void { } /** - * The store owns its live workspace. A later host mount or stale caller cannot - * overwrite content already committed for the target workspace. + * The store owns its live scope. A later host mount or stale caller cannot + * overwrite content already committed for the target scope. */ -export function switchAgentCanvasWorkspace( - _prevWorkspaceId: string | null | undefined, - nextWorkspaceId: string | null | undefined -): void { - const from = useAgentCanvasStore.getState().workspaceKey; - const to = normalizeAgentWorkspaceKey(nextWorkspaceId); +export function switchAgentCanvasScope(nextScopeId: string | null | undefined): void { + const from = useAgentCanvasStore.getState().scopeKey; + const to = normalizeAgentScopeKey(nextScopeId); if (from === to) return; - const rawNext = agentWorkspaceSnapshots.get(to); + const rawNext = agentScopeSnapshots.get(to); const nextSnapshotClone = rawNext ? structuredClone(rawNext) : null; if (from !== undefined) { @@ -1259,7 +1256,7 @@ export function switchAgentCanvasWorkspace( if (nextSnapshotClone) { useAgentCanvasStore.setState({ - workspaceKey: to, + scopeKey: to, primaryGroup: nextSnapshotClone.primaryGroup, secondaryGroup: nextSnapshotClone.secondaryGroup, tertiaryGroup: nextSnapshotClone.tertiaryGroup, @@ -1277,14 +1274,6 @@ export function switchAgentCanvasWorkspace( } -/** Drop cached canvas for a closed workspace (does not touch the live canvas unless user switches back). */ -export function removeAgentCanvasSnapshot(workspaceId: string): void { - const key = normalizeAgentWorkspaceKey(workspaceId); - agentWorkspaceSnapshots.delete(key); - const idx = agentSnapshotLruOrder.indexOf(key); - if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1); -} - const selectWholeCanvasStore = (state: CanvasStore) => state; export function useCanvasStore(): CanvasStore; @@ -1340,8 +1329,8 @@ export const useDragging = () => { })); }; -/** Includes suspended workspace snapshots so unmounting a view does not discard its document. */ +/** Includes suspended scope snapshots so unmounting a view does not discard its document. */ export function hasRetainedCanvasTab(tabId: string): boolean { - const states = [useAgentCanvasStore.getState(), useGitCanvasStore.getState(), useBottomTerminalCanvasStore.getState(), ...agentWorkspaceSnapshots.values()]; + const states = [useAgentCanvasStore.getState(), useGitCanvasStore.getState(), useBottomTerminalCanvasStore.getState(), ...agentScopeSnapshots.values()]; return states.some(state => [state.primaryGroup, state.secondaryGroup, state.tertiaryGroup].some(group => group.tabs.some(tab => tab.id === tabId))); } diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts index 89ada901bf..cf9ebb5d7c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/index.ts @@ -12,7 +12,6 @@ export { useActiveTabId, useLayout, useDragging, - switchAgentCanvasWorkspace, - removeAgentCanvasSnapshot, + switchAgentCanvasScope, clearAgentCanvasForPeerSwitch, } from './canvasStore'; diff --git a/src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx b/src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx new file mode 100644 index 0000000000..7955731587 --- /dev/null +++ b/src/web-ui/src/app/scenes/session/AuxPane.session-switch.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment jsdom + */ + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + switchAgentCanvasScope: vi.fn(), + canvasStore: { + addTab: vi.fn(), + switchToTab: vi.fn(), + findTabByMetadata: vi.fn(() => null), + updateTabContent: vi.fn(), + closeAllTabs: vi.fn(), + scopeKey: 'session-a' as string | undefined, + primaryGroup: { tabs: [] }, + secondaryGroup: { tabs: [] }, + tertiaryGroup: { tabs: [] }, + }, +})); + +vi.mock('../../components/panels/content-canvas', () => ({ + ContentCanvas: () =>
, + useCanvasStore: (selector: (state: typeof mocks.canvasStore) => unknown) => ( + selector(mocks.canvasStore) + ), +})); + +vi.mock('../../components/panels/content-canvas/stores', () => ({ + switchAgentCanvasScope: mocks.switchAgentCanvasScope, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + }), +})); + +import AuxPane from './AuxPane'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; + +vi.mock('./sessionPanelLayout', () => ({ + expandSessionAuxPane: vi.fn(), + collapseSessionAuxPane: vi.fn(), +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function setActiveSession(sessionId: string | null): void { + flowChatStore.setState(state => ({ ...state, activeSessionId: sessionId })); +} + +describe('AuxPane session canvas switching', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.switchAgentCanvasScope.mockReset(); + setActiveSession('session-a'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + setActiveSession(null); + }); + + it('scopes the canvas to the active session on mount', () => { + act(() => { + root.render(); + }); + + expect(mocks.switchAgentCanvasScope).toHaveBeenCalledWith('session-a'); + }); + + it('swaps canvas snapshots synchronously before the target session content can open', () => { + act(() => { + root.render(); + }); + mocks.switchAgentCanvasScope.mockClear(); + + const order: string[] = []; + mocks.switchAgentCanvasScope.mockImplementation(() => { + order.push('canvas-swapped'); + }); + + act(() => { + setActiveSession('session-b'); + order.push('open-target-content'); + }); + + expect(mocks.switchAgentCanvasScope).toHaveBeenCalledWith('session-b'); + expect(order).toEqual(['canvas-swapped', 'open-target-content']); + }); + + it('ignores store updates that keep the same active session and swaps back on return', () => { + act(() => { + root.render(); + }); + mocks.switchAgentCanvasScope.mockClear(); + + act(() => setActiveSession('session-b')); + act(() => setActiveSession('session-b')); + + expect(mocks.switchAgentCanvasScope).toHaveBeenCalledTimes(1); + + act(() => setActiveSession('session-a')); + + expect(mocks.switchAgentCanvasScope).toHaveBeenLastCalledWith('session-a'); + expect(mocks.switchAgentCanvasScope).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/app/scenes/session/AuxPane.tsx b/src/web-ui/src/app/scenes/session/AuxPane.tsx index 0f85b30514..442a495a95 100644 --- a/src/web-ui/src/app/scenes/session/AuxPane.tsx +++ b/src/web-ui/src/app/scenes/session/AuxPane.tsx @@ -10,12 +10,7 @@ import { ContentCanvas, useCanvasStore } from '../../components/panels/content-c import { usePanelTabCoordinator } from '../../components/panels/content-canvas/hooks/usePanelTabCoordinator'; import { TAB_EVENTS } from '../../components/panels/content-canvas/types'; import { collapseSessionAuxPane, expandSessionAuxPane } from './sessionPanelLayout'; -import { - switchAgentCanvasWorkspace, - removeAgentCanvasSnapshot, -} from '../../components/panels/content-canvas/stores'; -import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; -import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { switchAgentCanvasScope } from '../../components/panels/content-canvas/stores'; import { useI18n } from '@/infrastructure/i18n'; import type { PanelContent as OldPanelContent } from '../../components/panels/base/types'; import type { PanelContent } from '../../components/panels/content-canvas/types'; @@ -44,8 +39,6 @@ interface AuxPaneProps { const AuxPane = forwardRef( ({ workspacePath, isSceneActive = true, terminalResizeSuspended = false }, ref) => { const { t } = useI18n('components'); - const { workspace } = useCurrentWorkspace(); - const workspaceId = workspace?.id; const activeSessionId = useSyncExternalStore( flowChatStore.subscribe.bind(flowChatStore), () => flowChatStore.getState().activeSessionId, @@ -61,13 +54,13 @@ const AuxPane = forwardRef( const primaryGroup = useCanvasStore(state => state.primaryGroup); const secondaryGroup = useCanvasStore(state => state.secondaryGroup); const tertiaryGroup = useCanvasStore(state => state.tertiaryGroup); - const canvasWorkspaceKey = useCanvasStore(state => state.workspaceKey); + const canvasScopeKey = useCanvasStore(state => state.scopeKey); const { expandPanel, collapsePanel } = usePanelTabCoordinator({ visibleTabCount: [primaryGroup, secondaryGroup, tertiaryGroup] .reduce((count, group) => count + group.tabs.filter(tab => !tab.isHidden && isCanvasTabVisibleForSession(tab, activeSessionId), ).length, 0), - scopeKey: canvasWorkspaceKey, + scopeKey: canvasScopeKey, expandEventName: TAB_EVENTS.EXPAND_RIGHT_PANEL, onExpand: expandSessionAuxPane, onCollapse: collapseSessionAuxPane, @@ -122,41 +115,29 @@ const AuxPane = forwardRef( expandPanel, ]); - const prevWorkspaceIdRef = useRef(undefined); + const prevScopeKeyRef = useRef(undefined); - const syncAgentCanvasWorkspace = useCallback((next: string | undefined) => { - const prev = prevWorkspaceIdRef.current; + const syncAgentCanvasScope = useCallback((next: string | undefined) => { + const prev = prevScopeKeyRef.current; if (prev === next) return; - log.debug('Active workspace changed, swapping agent canvas snapshot', { + log.debug('Active session changed, swapping agent canvas snapshot', { from: prev ?? '(none)', to: next ?? '(none)', }); - switchAgentCanvasWorkspace(prev ?? null, next ?? null); - prevWorkspaceIdRef.current = next; + prevScopeKeyRef.current = next; + switchAgentCanvasScope(next ?? null); }, []); useEffect(() => { - syncAgentCanvasWorkspace(workspaceId); - }, [syncAgentCanvasWorkspace, workspaceId]); - - useEffect(() => { - const removeListener = workspaceManager.addEventListener((event) => { - if ( - event.type === 'workspace:switched' - || event.type === 'workspace:active-changed' - ) { - // WorkspaceManager emits these events synchronously while activation is - // still in progress. Swap the canvas before callers can open the target - // session's review tab; the context effect above remains a fallback. - syncAgentCanvasWorkspace(event.workspace?.id); - } - if (event.type === 'workspace:closed') { - removeAgentCanvasSnapshot(event.workspaceId); - } + syncAgentCanvasScope(flowChatStore.getState().activeSessionId ?? undefined); + // FlowChatStore notifies synchronously while a session switch is still in + // progress. Swap the canvas before callers can open content for the target + // session; the store itself ignores a swap to the scope already live. + return flowChatStore.subscribe(state => { + syncAgentCanvasScope(state.activeSessionId ?? undefined); }); - return () => removeListener(); - }, [syncAgentCanvasWorkspace]); + }, [syncAgentCanvasScope]); const handleInteraction = useCallback(async (itemId: string, userInput: string) => { log.debug('Panel interaction', { itemId, userInput }); diff --git a/src/web-ui/src/app/scenes/session/AuxPane.workspace-switch.test.tsx b/src/web-ui/src/app/scenes/session/AuxPane.workspace-switch.test.tsx deleted file mode 100644 index 1a7123d96d..0000000000 --- a/src/web-ui/src/app/scenes/session/AuxPane.workspace-switch.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import React, { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - workspaceId: 'workspace-a' as string | undefined, - workspaceEventListener: null as ((event: any) => void) | null, - removeWorkspaceEventListener: vi.fn(), - switchAgentCanvasWorkspace: vi.fn(), - removeAgentCanvasSnapshot: vi.fn(), - canvasStore: { - addTab: vi.fn(), - switchToTab: vi.fn(), - findTabByMetadata: vi.fn(() => null), - updateTabContent: vi.fn(), - closeAllTabs: vi.fn(), - primaryGroup: { tabs: [] }, - secondaryGroup: { tabs: [] }, - tertiaryGroup: { tabs: [] }, - }, -})); - -vi.mock('../../components/panels/content-canvas', () => ({ - ContentCanvas: () =>
, - useCanvasStore: (selector: (state: typeof mocks.canvasStore) => unknown) => ( - selector(mocks.canvasStore) - ), -})); - -vi.mock('../../components/panels/content-canvas/stores', () => ({ - switchAgentCanvasWorkspace: mocks.switchAgentCanvasWorkspace, - removeAgentCanvasSnapshot: mocks.removeAgentCanvasSnapshot, -})); - -vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ - useCurrentWorkspace: () => ({ - workspace: mocks.workspaceId ? { id: mocks.workspaceId } : null, - }), -})); - -vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ - workspaceManager: { - addEventListener: (listener: (event: any) => void) => { - mocks.workspaceEventListener = listener; - return mocks.removeWorkspaceEventListener; - }, - }, -})); - -vi.mock('@/infrastructure/i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }), -})); - -vi.mock('@/shared/utils/logger', () => ({ - createLogger: () => ({ - debug: vi.fn(), - }), -})); - -import AuxPane from './AuxPane'; - -vi.mock('./sessionPanelLayout', () => ({ - expandSessionAuxPane: vi.fn(), - collapseSessionAuxPane: vi.fn(), -})); - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -describe('AuxPane workspace canvas switching', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - mocks.workspaceId = 'workspace-a'; - mocks.workspaceEventListener = null; - mocks.removeWorkspaceEventListener.mockReset(); - mocks.switchAgentCanvasWorkspace.mockReset(); - mocks.removeAgentCanvasSnapshot.mockReset(); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - - it('swaps canvas snapshots synchronously before the target review tab can open', () => { - act(() => { - root.render(); - }); - - expect(mocks.switchAgentCanvasWorkspace).toHaveBeenCalledWith(null, 'workspace-a'); - mocks.switchAgentCanvasWorkspace.mockClear(); - - const order: string[] = []; - mocks.switchAgentCanvasWorkspace.mockImplementation(() => { - order.push('canvas-swapped'); - }); - - act(() => { - mocks.workspaceEventListener?.({ - type: 'workspace:switched', - workspace: { id: 'workspace-b' }, - }); - order.push('open-target-review'); - }); - - expect(mocks.switchAgentCanvasWorkspace).toHaveBeenCalledWith( - 'workspace-a', - 'workspace-b', - ); - expect(order).toEqual(['canvas-swapped', 'open-target-review']); - - act(() => { - mocks.workspaceEventListener?.({ - type: 'workspace:active-changed', - workspace: { id: 'workspace-b' }, - }); - }); - - expect(mocks.switchAgentCanvasWorkspace).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/web-ui/src/app/workbench/workbenchContent.test.ts b/src/web-ui/src/app/workbench/workbenchContent.test.ts index 5da77b4df4..8500a55606 100644 --- a/src/web-ui/src/app/workbench/workbenchContent.test.ts +++ b/src/web-ui/src/app/workbench/workbenchContent.test.ts @@ -6,7 +6,7 @@ import { registerContentCloseGuard } from './contentResourceLifecycle'; import { fileTabManager } from '@/shared/services/FileTabManager'; import { openFileInBestTarget, createTerminalTab, createGitCodeEditorTab, createTab } from '@/shared/utils/tabUtils'; import { openContentInBestTarget, openWorkbenchContent } from '@/shared/services/workbenchContentService'; -import { clearAgentCanvasForPeerSwitch, switchAgentCanvasWorkspace, useAgentCanvasStore, useGitCanvasStore } from '../components/panels/content-canvas/stores'; +import { clearAgentCanvasForPeerSwitch, switchAgentCanvasScope, useAgentCanvasStore, useGitCanvasStore } from '../components/panels/content-canvas/stores'; import { appManager } from '../services/AppManager'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import type { Session } from '@/flow_chat/types/flow-chat'; @@ -122,16 +122,16 @@ describe('workbench content navigation', () => { fileTabManager.openFile({ filePath: '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/project/a.ts', sceneJustOpened: true }); openFileInBestTarget({ filePath: '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/project/a.ts', jumpToLine: 42 }, { source: 'project-nav' }); const canvas = useAgentCanvasStore.getState(); - expect(canvas.workspaceKey).toBe('project'); + expect(canvas.scopeKey).toBe('session-a'); expect(canvas.primaryGroup.tabs).toHaveLength(1); expect(canvas.primaryGroup.tabs[0].content.data).toMatchObject({ filePath: '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/project/a.ts', jumpToLine: 42 }); expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId)).toEqual(['session-a']); expect(appManager.getState().layout.rightPanelCollapsed).toBe(false); expect(Object.values(useContentResourceStore.getState().resources)).toHaveLength(0); - // AuxPane's later first mount and stale workspace events cannot reset this write. - switchAgentCanvasWorkspace(null, 'project'); - switchAgentCanvasWorkspace('previous-workspace', 'project'); + // AuxPane's later first mount and stale scope syncs cannot reset this write. + switchAgentCanvasScope('session-a'); + switchAgentCanvasScope('session-a'); expect(useAgentCanvasStore.getState().primaryGroup.tabs[0].id).toBe(canvas.primaryGroup.tabs[0].id); }); @@ -149,15 +149,15 @@ describe('workbench content navigation', () => { it('chooses the matching workspace tab while a different workspace session is active', () => { const target = openSession(); const other = openSession('other-session', { workspaceId: undefined, workspacePath: '/other' }); - switchAgentCanvasWorkspace(undefined, 'other'); + switchAgentCanvasScope('other-session'); useAgentCanvasStore.getState().addTab({ type: 'text-viewer', title: 'Other', data: { content: 'draft' } }, 'active'); const previous = useAgentCanvasStore.getState().primaryGroup.tabs[0]; fileTabManager.openFile({ filePath: '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/project/a.ts', workspacePath: '/project' }); expect(useSceneStore.getState().activeTabId).toBe(getSessionSceneTabId(target)); expect(useSceneStore.getState().openTabs.map(tab => tab.session)).toEqual([target, other]); - expect(useAgentCanvasStore.getState().workspaceKey).toBe('project'); + expect(useAgentCanvasStore.getState().scopeKey).toBe('session-a'); expect(useAgentCanvasStore.getState().primaryGroup.tabs[0].content.data.filePath).toBe('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/project/a.ts'); - switchAgentCanvasWorkspace(undefined, 'other'); + switchAgentCanvasScope('other-session'); expect(useAgentCanvasStore.getState().primaryGroup.tabs.map(tab => tab.id)).toEqual([previous.id]); }); @@ -185,7 +185,7 @@ describe('workbench content navigation', () => { useSceneStore.getState().openScene('git'); fileTabManager.openFile({ filePath: 'a.ts', workspacePath: sshWorkspace.rootPath, remoteConnectionId: 'ssh-a' }); expect(useSceneStore.getState().activeTabId).toBe(getSessionSceneTabId(target)); - expect(useAgentCanvasStore.getState().workspaceKey).toBe(sshWorkspace.id); + expect(useAgentCanvasStore.getState().scopeKey).toBe('ssh-session'); expect(useAgentCanvasStore.getState().primaryGroup.tabs[0].content.metadata?.resourceScope).toEqual({ surfaceId, workspaceId: sshWorkspace.id, workspacePath: sshWorkspace.rootPath, remoteConnectionId: 'ssh-a', }); @@ -200,7 +200,7 @@ describe('workbench content navigation', () => { projectWorkspacePath: '/project' }); fileTabManager.openFile({ filePath: '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/project/a.ts', workspacePath: '/project' }); expect(useSceneStore.getState().activeTabId).toBe(getSessionSceneTabId(target)); - expect(useAgentCanvasStore.getState().workspaceKey).toBe('project'); + expect(useAgentCanvasStore.getState().scopeKey).toBe('worktree-session'); expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(1); }); @@ -332,11 +332,11 @@ describe('workbench content navigation', () => { } finally { stop(); } }); - it('activates an existing session tab before committing and preserves the previous workspace canvas', async () => { + it('activates an existing session tab before committing and preserves the previous scope canvas', async () => { openSession(); useSceneStore.getState().openScene('git'); flowChatStore.setState(state => ({ ...state, activeSessionId: null })); - switchAgentCanvasWorkspace(null, 'other-workspace'); + switchAgentCanvasScope('scratch-session'); useAgentCanvasStore.getState().addTab({ type: 'markdown-viewer', title: 'Previous', data: 'draft' }, 'active'); const previous = useAgentCanvasStore.getState().primaryGroup.tabs[0]; const stop = registerSessionSceneNavigation({ current: () => null, isActive: () => false, @@ -346,10 +346,10 @@ describe('workbench content navigation', () => { } }); try { fileTabManager.openFile({ filePath: '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/project/a.ts' }); - await vi.waitFor(() => expect(useAgentCanvasStore.getState().workspaceKey).toBe('project')); + await vi.waitFor(() => expect(useAgentCanvasStore.getState().scopeKey).toBe('session-a')); expect(useAgentCanvasStore.getState().primaryGroup.tabs[0].content.data.filePath).toBe('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/project/a.ts'); expect(flowChatStore.getState().activeSessionId).toBe('session-a'); - switchAgentCanvasWorkspace(null, 'other-workspace'); + switchAgentCanvasScope('scratch-session'); expect(useAgentCanvasStore.getState().primaryGroup.tabs.map(tab => tab.id)).toEqual([previous.id]); } finally { stop(); } }); diff --git a/src/web-ui/src/shared/services/workbenchContentService.ts b/src/web-ui/src/shared/services/workbenchContentService.ts index 0fb8b5cabe..6d983746db 100644 --- a/src/web-ui/src/shared/services/workbenchContentService.ts +++ b/src/web-ui/src/shared/services/workbenchContentService.ts @@ -1,9 +1,8 @@ import { useSceneStore } from '@/app/stores/sceneStore'; import { useContentResourceStore, contentResourceIdentity, mergeContentOpenIntent } from '@/app/workbench/contentResourceStore'; -import { switchAgentCanvasWorkspace, useAgentCanvasStore, useGitCanvasStore } from '@/app/components/panels/content-canvas/stores'; +import { switchAgentCanvasScope, useAgentCanvasStore, useGitCanvasStore } from '@/app/components/panels/content-canvas/stores'; import type { EditorGroupId } from '@/app/components/panels/content-canvas/types'; import type { SessionSceneTarget } from '@/app/components/SceneBar/types'; -import { resolveSessionSceneWorkspace } from '@/app/services/sessionSceneTarget'; import { expandSessionAuxPane } from '@/app/scenes/session/sessionPanelLayout'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import type { Session } from '@/flow_chat/types/flow-chat'; @@ -123,10 +122,9 @@ export function openContentInBestTarget(content: PanelContent, options: ContentO isCurrent, onActivated: () => { if (!isCurrent()) return; - // Snapshot selection must precede the write, including before AuxPane's first mount. - const session = flowChatStore.getState().sessions.get(target.sessionId)!; - const workspace = resolveSessionSceneWorkspace(session, workspaceManager.getState().openedWorkspaces.values()); - switchAgentCanvasWorkspace(undefined, workspace?.id); + // Scope selection must precede the write, including before AuxPane's first + // mount: the canvas is owned by the session that shows the content. + switchAgentCanvasScope(target.sessionId); openCanvasContent('agent', content, { ...options, scope }); expandSessionAuxPane(); }, From 17e0523a3498aedb32e19faa03504decf3d7ca1c Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 11:17:10 +0800 Subject: [PATCH 3/3] fix(web-ui): activate the workspace a session is listed under A session in a linked worktree is listed under its execution workspace, while its project only owns persistence. Activation and scene activity compared the persistence project, so selecting a worktree session activated the project workspace instead. The workspace bootstrap then treated the selection as foreign to the newly active workspace and replaced it with that workspace's most recent session, which is why every session in another worktree jumped back to the first session of the main project. Resolve the execution workspace first for navigation and keep the project (and legacy path metadata) as the fallback for records that carry no workspace identity. --- .../services/sessionSceneLifecycle.test.ts | 32 +++++++++++++++++++ .../src/app/services/sessionSceneLifecycle.ts | 8 +++-- .../app/services/sessionSceneTarget.test.ts | 28 +++++++++++++++- .../src/app/services/sessionSceneTarget.ts | 18 +++++++++++ .../services/sessionActivation.test.ts | 27 ++++++++++++++++ .../flow_chat/services/sessionActivation.ts | 4 +-- 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts index 10f0305555..0d84f1201b 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts @@ -189,6 +189,38 @@ describe('Session scene resource lifetime with real stores', () => { }, ); + it('keeps a worktree session in its execution workspace instead of activating its project', async () => { + stop?.(); + const project = { + id: 'project', rootPath: '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/projects/main', workspaceKind: WorkspaceKind.Normal, + } as WorkspaceInfo; + const worktree = { + id: 'worktree', rootPath: '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/projects/tree', workspaceKind: WorkspaceKind.Normal, + worktree: { isMain: false, mainRepoPath: project.rootPath, mainWorkspaceId: project.id }, + } as WorkspaceInfo; + const active = session('tree-session', { + workspaceId: worktree.id, projectWorkspaceId: project.id, + workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + }); + const workspaceState = { + ...workspaceManager.getState(), currentWorkspace: worktree, activeWorkspaceId: worktree.id, + openedWorkspaces: new Map([[project.id, project], [worktree.id, worktree]]), + }; + vi.spyOn(workspaceManager, 'getState').mockImplementation(() => workspaceState); + const setActiveWorkspace = vi.spyOn(workspaceManager, 'setActiveWorkspace').mockResolvedValue(worktree); + select([active], active.sessionId); + stop = startSessionSceneLifecycle(); + + useSceneStore.getState().openScene('session'); + + expect(useSceneStore.getState().pendingTabId).toBeNull(); + expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId)).toEqual([active.sessionId]); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(setActiveWorkspace).not.toHaveBeenCalled(); + expect(workspaceState.activeWorkspaceId).toBe(worktree.id); + expect(flowChatStore.getActiveSession()).toBe(active); + }); + it('does not reopen a closed tab when its session is updated in the background', () => { const first = session('a'); select([first], 'a'); diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index 59850878ca..093b345065 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -6,7 +6,7 @@ import { createLogger } from '@/shared/utils/logger'; import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; import { isSessionSceneId, type SessionSceneTarget } from '../components/SceneBar/types'; import { startSessionAuxPaneMemory } from '../scenes/session/sessionAuxPaneMemory'; -import { resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; +import { resolveSessionActivationWorkspace, resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; const log = createLogger('SessionSceneLifecycle'); @@ -29,7 +29,11 @@ export function startSessionSceneLifecycle(): () => void { if (target.surfaceId !== getActiveSurfaceId() || current()?.sessionId !== target.sessionId) return false; const session = flowChatStore.getActiveSession()!; const state = workspaceManager.getState(); - const workspace = resolveSessionSceneWorkspace(session, state.openedWorkspaces.values()); + // The execution workspace owns the session, not the project that persists a + // linked worktree. Comparing the project would make the scene permanently + // inactive there and re-activate it — and the project workspace — on every + // change, which also cancels the selection the user made. + const workspace = resolveSessionActivationWorkspace(session, state.openedWorkspaces.values()); return workspace ? workspace.id === state.activeWorkspaceId : !state.currentWorkspace; }, activate: async (target, isCurrent) => { diff --git a/src/web-ui/src/app/services/sessionSceneTarget.test.ts b/src/web-ui/src/app/services/sessionSceneTarget.test.ts index ecc7f59a6b..6794835cdd 100644 --- a/src/web-ui/src/app/services/sessionSceneTarget.test.ts +++ b/src/web-ui/src/app/services/sessionSceneTarget.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Session } from '@/flow_chat/types/flow-chat'; import type { WorkspaceInfo } from '@/shared/types'; import { getSessionSceneTabId } from '../components/SceneBar/types'; -import { resolveSessionSceneTarget } from './sessionSceneTarget'; +import { resolveSessionActivationWorkspace, resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; const session = (overrides: Partial): Session => ({ sessionId: 'session', title: 'Session', config: {}, dialogTurns: [], @@ -47,3 +47,29 @@ describe('workspace session tab identity', () => { expect(tabId(remote, 'local', [local])).not.toBe(tabId(session({ workspaceId: local.id }))); }); }); + +describe('session activation workspace identity', () => { + const project = { id: 'project', rootPath: '/project' } as WorkspaceInfo; + const worktree = { id: 'worktree', rootPath: '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/worktrees/task' } as WorkspaceInfo; + + it('activates the worktree a session is listed under, not its persistence project', () => { + const value = session({ + workspaceId: worktree.id, projectWorkspaceId: project.id, + workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + }); + + expect(resolveSessionSceneWorkspace(value, [project, worktree])?.id).toBe(project.id); + expect(resolveSessionActivationWorkspace(value, [project, worktree])?.id).toBe(worktree.id); + }); + + it('falls back to the project only for records that carry no workspace identity', () => { + expect(resolveSessionActivationWorkspace(session({ workspacePath: '/project' }), [project])?.id).toBe(project.id); + expect(resolveSessionActivationWorkspace(session({ projectWorkspaceId: project.id }), [project])?.id).toBe(project.id); + expect(resolveSessionActivationWorkspace(session({ config: { workspaceId: worktree.id } }), [project, worktree])?.id) + .toBe(worktree.id); + }); + + it('stays unresolved when the listed workspace is not open here', () => { + expect(resolveSessionActivationWorkspace(session({ workspaceId: 'closed' }), [project, worktree])).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/app/services/sessionSceneTarget.ts b/src/web-ui/src/app/services/sessionSceneTarget.ts index 570cf2f5fa..10f214b3a4 100644 --- a/src/web-ui/src/app/services/sessionSceneTarget.ts +++ b/src/web-ui/src/app/services/sessionSceneTarget.ts @@ -20,6 +20,24 @@ export function sessionSceneWorkspaceKey(workspaceId: string): string { return JSON.stringify(['workspace', workspaceId]); } +/** + * Workspace a session is listed under, and therefore the one navigation must + * activate. A session in a linked worktree belongs to its execution workspace; + * its project only owns persistence, so activating the project would move the + * surface into a workspace the session is not listed under, where the workspace + * bootstrap then replaces the selection. Legacy path resolution only serves + * pre-ID records that carry no workspace identity at all. + */ +export function resolveSessionActivationWorkspace(session: Session, workspaces: Iterable) { + return findWorkspaceForSession({ + ...session, + workspaceId: session.workspaceId || session.config?.workspaceId || session.projectWorkspaceId || session.config?.projectWorkspaceId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId || session.config?.remoteConnectionId, + remoteSshHost: session.remoteSshHost || session.config?.remoteSshHost, + }, workspaces); +} + export function resolveSessionSceneTarget( session: Session, workspaces: Iterable, diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts index 70062871bd..a891f02088 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -121,6 +121,33 @@ describe('openMainSession resource activation', () => { expect(mocks.sceneState.openScene).not.toHaveBeenCalled(); }); + it('activates the workspace a worktree session is listed under, not its project', async () => { + const project = { id: 'project', rootPath: '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/projects/main', workspaceKind: 'normal' }; + const worktree = { + id: 'worktree', rootPath: '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/projects/tree', workspaceKind: 'normal', + worktree: { isMain: false, mainRepoPath: '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/projects/main', mainWorkspaceId: project.id }, + }; + mocks.workspaceState.currentWorkspace = project; + mocks.workspaceState.activeWorkspaceId = project.id; + mocks.workspaceState.openedWorkspaces = new Map([[project.id, project], [worktree.id, worktree]]); + mocks.flowChatState.sessions.set('tree-session', { + sessionId: 'tree-session', workspaceId: worktree.id, projectWorkspaceId: project.id, + workspacePath: worktree.rootPath, projectWorkspacePath: project.rootPath, + }); + mocks.setActiveWorkspace.mockImplementation(async (id: string) => { + mocks.workspaceState.activeWorkspaceId = id; + }); + mocks.switchChatSession.mockImplementation(async (id: string) => { + mocks.flowChatState.activeSessionId = id; + }); + + await openMainSession('tree-session'); + + expect(mocks.setActiveWorkspace.mock.calls.map(([id]) => id)).toEqual([worktree.id]); + expect(mocks.switchChatSession.mock.calls.map(([id]) => id)).toEqual(['tree-session']); + expect(mocks.workspaceState.activeWorkspaceId).toBe(worktree.id); + }); + it('orders workspace activation and only opens the latest requested session', async () => { const a = { id: 'a', rootPath: '/a' }; const b = { id: 'b', rootPath: '/b' }; diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts index 86415112c6..7df075a211 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -5,7 +5,7 @@ import { flowChatManager } from './FlowChatManager'; import { syncSessionToModernStore } from './storeSync'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; -import { resolveSessionSceneWorkspace } from '@/app/services/sessionSceneTarget'; +import { resolveSessionActivationWorkspace } from '@/app/services/sessionSceneTarget'; import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import { i18nService } from '@/infrastructure/i18n'; @@ -38,7 +38,7 @@ export async function activateMainSession(sessionId: string, options?: SessionAc const isCurrent = () => scope.isCurrent() && request === activationRequest && (options?.isCurrent?.() ?? true); if (!isCurrent()) return false; const session = flowChatStore.getState().sessions.get(sessionId); - const workspace = session && resolveSessionSceneWorkspace(session, workspaceManager.getState().openedWorkspaces.values()); + const workspace = session && resolveSessionActivationWorkspace(session, workspaceManager.getState().openedWorkspaces.values()); const workspaceId = options?.workspaceId ?? workspace?.id; if (session && sessionProjectWorkspacePath(session) && !workspaceId && workspaceManager.getState().currentWorkspace) {