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/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.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 c07e9be6ce..093b345065 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -5,7 +5,8 @@ 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 { resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; +import { startSessionAuxPaneMemory } from '../scenes/session/sessionAuxPaneMemory'; +import { resolveSessionActivationWorkspace, 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( @@ -27,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) => { @@ -112,6 +118,7 @@ export function startSessionSceneLifecycle(): () => void { unsubscribeScenes(); unsubscribeWorkspaces(); stopNavigation(); + stopAuxPaneMemory(); stopProjectionSync(); }; } 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/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/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) { 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(); },