Skip to content

Commit 08da024

Browse files
authored
Merge pull request #3168 from kev1n77/fmy/ui-main
fix(web-ui): scope the session right pane and workspace activation to the session
2 parents 9216d91 + 17e0523 commit 08da024

19 files changed

Lines changed: 499 additions & 247 deletions

‎src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import SceneBar from './SceneBar';
1111
import { useSceneStore } from '../../stores/sceneStore';
1212
import { useContentResourceStore } from '../../workbench/contentResourceStore';
1313
import { writeSessionTabDrag } from '../../workbench/canvasTabTransfer';
14-
import { clearAgentCanvasForPeerSwitch, switchAgentCanvasWorkspace, useAgentCanvasStore } from '../panels/content-canvas/stores';
14+
import { clearAgentCanvasForPeerSwitch, switchAgentCanvasScope, useAgentCanvasStore } from '../panels/content-canvas/stores';
1515
import { activateSurface } from '@/infrastructure/peer-device/deviceSurface';
1616

1717
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -170,15 +170,15 @@ describe('SceneBar overflow navigation', () => {
170170
elsewhere.remove();
171171
});
172172

173-
it.each(['end', 'remove', 'workspace-switch', 'device-switch'])('withdraws availability when the source is invalidated: %s', action => {
173+
it.each(['end', 'remove', 'scope-switch', 'device-switch'])('withdraws availability when the source is invalidated: %s', action => {
174174
renderSceneBar();
175175
act(() => { startSessionTabDrag(); });
176176
expect(container.querySelector('[data-openbitfun-part="dropHint"]')).not.toBeNull();
177177
act(() => {
178178
const store = useAgentCanvasStore.getState();
179179
if (action === 'end') store.endDrag();
180180
if (action === 'remove') store.detachTab(store.draggingTabId!, 'primary');
181-
if (action === 'workspace-switch') switchAgentCanvasWorkspace(undefined, 'another-workspace');
181+
if (action === 'scope-switch') switchAgentCanvasScope('another-session');
182182
if (action === 'device-switch') activateSurface('peer');
183183
});
184184
expect(container.querySelector('[data-openbitfun-part="dropHint"]')).toBeNull();

‎src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
7171
const openMissionControl = useCanvasStore(state => state.openMissionControl);
7272
const activeBtwSessionTab = useCanvasStore(state => selectActiveBtwSessionTab(state as any));
7373
const activeBtwSessionData = activeBtwSessionTab?.content.data as BtwSessionPanelData | undefined;
74+
const canvasScopeKey = useCanvasStore(state => state.scopeKey);
7475
const { workspace: currentWorkspace } = useCurrentWorkspace();
7576
const activeSessionId = useSyncExternalStore(
7677
flowChatStore.subscribe.bind(flowChatStore),
@@ -79,6 +80,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
7980
);
8081
const currentWorkspaceId = currentWorkspace?.id;
8182
const lastSyncedBtwTabIdRef = useRef<string | null>(null);
83+
const lastCanvasScopeKeyRef = useRef(canvasScopeKey);
8284
// Initialize hooks
8385
const { handleCloseWithDirtyCheck, handleCloseAllWithDirtyCheck } = useTabLifecycle({
8486
mode,
@@ -93,6 +95,9 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
9395
});
9496

9597
useEffect(() => {
98+
const canvasScopeChanged = lastCanvasScopeKeyRef.current !== canvasScopeKey;
99+
lastCanvasScopeKeyRef.current = canvasScopeKey;
100+
96101
if (mode !== 'agent' || !activeBtwSessionTab?.id || !activeBtwSessionData?.parentSessionId) {
97102
lastSyncedBtwTabIdRef.current = null;
98103
return;
@@ -102,6 +107,14 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
102107
return;
103108
}
104109

110+
// Restoring another session's canvas is not a request to reopen the tabs it
111+
// had. Navigating there would pull the user away from the session they just
112+
// switched to, so the restored tab only loses its "unsynced" state.
113+
if (canvasScopeChanged) {
114+
lastSyncedBtwTabIdRef.current = activeBtwSessionTab.id;
115+
return;
116+
}
117+
105118
// Only sync when the BTW session belongs to the current workspace,
106119
// preventing the wrong session from opening when switching workspaces.
107120
// Workspace ID is the identity; the path check only serves tabs restored
@@ -127,6 +140,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
127140
activeBtwSessionData?.workspaceId,
128141
activeBtwSessionData?.workspacePath,
129142
activeBtwSessionTab?.id,
143+
canvasScopeKey,
130144
currentWorkspaceId,
131145
mode,
132146
workspacePath,

‎src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx‎

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
55
import {
66
CanvasStoreModeContext,
77
clearAgentCanvasForPeerSwitch,
8-
switchAgentCanvasWorkspace,
8+
switchAgentCanvasScope,
99
useAgentCanvasStore,
1010
useBottomTerminalCanvasStore,
1111
useCanvasStore,
@@ -56,7 +56,7 @@ function CanvasProbe({ mode, onReveal }: { mode: CanvasStoreMode; onReveal?: ()
5656
const expandBottom = () => expandSessionBottomTerminalPane(240);
5757

5858
function PanelProbe({ bottom = false }: { bottom?: boolean }) {
59-
const scopeKey = useCanvasStore(state => state.workspaceKey);
59+
const scopeKey = useCanvasStore(state => state.scopeKey);
6060
const visibleTabCount = useCanvasStore(state => (
6161
[state.primaryGroup, state.secondaryGroup, state.tertiaryGroup]
6262
.reduce((count, group) => count + group.tabs.filter(tab => !tab.isHidden).length, 0)
@@ -206,26 +206,28 @@ describe('canvas host panel ownership', () => {
206206
expect(appManager.getState().layout.rightPanelCollapsed).toBe(true);
207207
});
208208

209-
it('restores panel visibility when workspace content returns', async () => {
210-
switchAgentCanvasWorkspace(null, 'workspace-a');
209+
it('restores a scope snapshot without opening the panel for the host', async () => {
210+
switchAgentCanvasScope('session-a');
211211
useAgentCanvasStore.getState().addTab(content('session'), 'active');
212212
await act(async () => root.render(<Hosts />));
213213
const tab = useAgentCanvasStore.getState().primaryGroup.tabs[0];
214214
await act(async () => useAgentCanvasStore.getState().updateTabContent(tab.id, 'primary', content('updated')));
215215
expect(appManager.getState().layout.rightPanelCollapsed).toBe(true);
216216
await act(async () => {
217-
switchAgentCanvasWorkspace('workspace-a', 'workspace-b');
217+
switchAgentCanvasScope('session-b');
218218
root.render(<Hosts />);
219219
});
220220
await act(async () => {
221-
switchAgentCanvasWorkspace('workspace-b', 'workspace-a');
221+
switchAgentCanvasScope('session-a');
222222
root.render(<Hosts />);
223223
});
224+
// The entered scope owns its own open state; content returning with a scope
225+
// swap must not reopen a panel the host restored as collapsed.
224226
expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(1);
225-
expect(appManager.getState().layout.rightPanelCollapsed).toBe(false);
227+
expect(appManager.getState().layout.rightPanelCollapsed).toBe(true);
226228
await act(async () => root.render(null));
227229
await act(async () => root.render(<Hosts />));
228-
expect(appManager.getState().layout.rightPanelCollapsed).toBe(false);
230+
expect(appManager.getState().layout.rightPanelCollapsed).toBe(true);
229231
});
230232

231233
it('keeps an explicitly opened empty panel and counts all editor groups when closing', async () => {
@@ -245,18 +247,19 @@ describe('canvas host panel ownership', () => {
245247
expect(appManager.getState().layout.rightPanelCollapsed).toBe(true);
246248
});
247249

248-
it('collapses when a workspace snapshot has no visible content before the shell rerenders', async () => {
249-
switchAgentCanvasWorkspace(null, 'workspace-a');
250-
useAgentCanvasStore.getState().addTab(content('workspace-a'), 'active');
250+
it('keeps canvas content per scope instead of sharing it between scopes', async () => {
251+
switchAgentCanvasScope('session-a');
252+
useAgentCanvasStore.getState().addTab(content('session-a'), 'active');
251253
appManager.updateLayout({ rightPanelCollapsed: false });
252254
await act(async () => root.render(<Hosts />));
253-
await act(async () => switchAgentCanvasWorkspace('workspace-a', 'workspace-b'));
254-
expect(useAgentCanvasStore.getState().workspaceKey).toBe('workspace-b');
255+
await act(async () => switchAgentCanvasScope('session-b'));
256+
expect(useAgentCanvasStore.getState().scopeKey).toBe('session-b');
255257
expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(0);
256-
expect(appManager.getState().layout.rightPanelCollapsed).toBe(true);
257-
await act(async () => switchAgentCanvasWorkspace('workspace-b', 'workspace-a'));
258-
expect(useAgentCanvasStore.getState().primaryGroup.tabs).toHaveLength(1);
259-
expect(appManager.getState().layout.rightPanelCollapsed).toBe(false);
258+
await act(async () => useAgentCanvasStore.getState().addTab(content('session-b'), 'active'));
259+
await act(async () => switchAgentCanvasScope('session-a'));
260+
expect(useAgentCanvasStore.getState().primaryGroup.tabs.map(tab => tab.content.title)).toEqual(['session-a']);
261+
await act(async () => switchAgentCanvasScope('session-b'));
262+
expect(useAgentCanvasStore.getState().primaryGroup.tabs.map(tab => tab.content.title)).toEqual(['session-b']);
260263
});
261264

262265
it('keeps Git and bottom terminal operations scoped to their hosts', async () => {

‎src/web-ui/src/app/components/panels/content-canvas/hooks/usePanelTabCoordinator.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export function usePanelTabCoordinator({
2525
useEffect(() => {
2626
const previous = previousRef.current;
2727
previousRef.current = { visibleTabCount, scopeKey };
28+
// A scope change restores the content of the scope being entered. That
29+
// scope's open state is owned by the host, which restores it together with
30+
// the content, so a restore is not a content transition here.
31+
if (previous.scopeKey !== scopeKey) {
32+
return;
33+
}
2834
if (
2935
previous.visibleTabCount > 0
3036
&& visibleTabCount === 0

‎src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts‎

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import { normalizePath } from '@/shared/utils/pathUtils';
3030
// ==================== Store State Types ====================
3131

3232
interface CanvasStoreState {
33-
/** Live content scope, committed with workspace swaps and excluded from snapshots. */
34-
workspaceKey?: string;
33+
/** Live content scope, committed with scope swaps and excluded from snapshots. */
34+
scopeKey?: string;
3535
primaryGroup: EditorGroupState;
3636
secondaryGroup: EditorGroupState;
3737
tertiaryGroup: EditorGroupState;
@@ -159,7 +159,7 @@ type CanvasStore = CanvasStoreState & CanvasStoreActions;
159159
// ==================== Initial State ====================
160160

161161
const initialState: CanvasStoreState = {
162-
workspaceKey: undefined,
162+
scopeKey: undefined,
163163
primaryGroup: createEditorGroupState(),
164164
secondaryGroup: createEditorGroupState(),
165165
tertiaryGroup: createEditorGroupState(),
@@ -1169,15 +1169,15 @@ export const useAgentCanvasStore = createCanvasStoreHook();
11691169
export const useGitCanvasStore = createCanvasStoreHook();
11701170
export const useBottomTerminalCanvasStore = createCanvasStoreHook();
11711171

1172-
// ==================== Agent canvas: per-workspace snapshots (AuxPane / Session scene) ====================
1173-
// Switching active workspace saves the current agent canvas under the previous workspace id and restores
1174-
// the snapshot for the next id, so remote/local tabs coexist across workspace switches.
1172+
// ==================== Agent canvas: per-scope snapshots (AuxPane / Session scene) ====================
1173+
// The agent canvas is scoped to the active session. Leaving a session saves its canvas under that
1174+
// session id and entering another one restores its own tabs, so two sessions never share content.
11751175

1176-
const AGENT_CANVAS_SNAPSHOT_MAX = 12;
1177-
const agentWorkspaceSnapshots = new Map<string, CanvasStoreState>();
1176+
const AGENT_CANVAS_SNAPSHOT_MAX = 24;
1177+
const agentScopeSnapshots = new Map<string, CanvasStoreState>();
11781178
const agentSnapshotLruOrder: string[] = [];
11791179

1180-
function normalizeAgentWorkspaceKey(id: string | null | undefined): string {
1180+
function normalizeAgentScopeKey(id: string | null | undefined): string {
11811181
return id ?? '__none__';
11821182
}
11831183

@@ -1200,20 +1200,20 @@ function rememberAgentSnapshot(key: string, snapshot: CanvasStoreState): void {
12001200
const clone = structuredClone(snapshot);
12011201
clone.draggingTabId = null;
12021202
clone.draggingFromGroupId = null;
1203-
agentWorkspaceSnapshots.set(key, clone);
1203+
agentScopeSnapshots.set(key, clone);
12041204
const idx = agentSnapshotLruOrder.indexOf(key);
12051205
if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1);
12061206
agentSnapshotLruOrder.push(key);
1207-
while (agentWorkspaceSnapshots.size > AGENT_CANVAS_SNAPSHOT_MAX) {
1207+
while (agentScopeSnapshots.size > AGENT_CANVAS_SNAPSHOT_MAX) {
12081208
const evict = agentSnapshotLruOrder.shift();
12091209
if (!evict) break;
1210-
agentWorkspaceSnapshots.delete(evict);
1210+
agentScopeSnapshots.delete(evict);
12111211
}
12121212
}
12131213

1214-
function applyEmptyAgentCanvas(workspaceKey?: string): void {
1214+
function applyEmptyAgentCanvas(scopeKey?: string): void {
12151215
useAgentCanvasStore.setState({
1216-
workspaceKey,
1216+
scopeKey,
12171217
primaryGroup: createEditorGroupState(),
12181218
secondaryGroup: createEditorGroupState(),
12191219
tertiaryGroup: createEditorGroupState(),
@@ -1227,29 +1227,26 @@ function applyEmptyAgentCanvas(workspaceKey?: string): void {
12271227
});
12281228
}
12291229

1230-
/** Clear agent canvas workspace snapshots when entering/exiting Peer Device Mode. */
1230+
/** Clear agent canvas snapshots when entering/exiting Peer Device Mode. */
12311231
export function clearAgentCanvasForPeerSwitch(): void {
1232-
agentWorkspaceSnapshots.clear();
1232+
agentScopeSnapshots.clear();
12331233
agentSnapshotLruOrder.length = 0;
12341234
applyEmptyAgentCanvas();
12351235
useGitCanvasStore.getState().reset();
12361236
useBottomTerminalCanvasStore.getState().reset();
12371237
}
12381238

12391239
/**
1240-
* The store owns its live workspace. A later host mount or stale caller cannot
1241-
* overwrite content already committed for the target workspace.
1240+
* The store owns its live scope. A later host mount or stale caller cannot
1241+
* overwrite content already committed for the target scope.
12421242
*/
1243-
export function switchAgentCanvasWorkspace(
1244-
_prevWorkspaceId: string | null | undefined,
1245-
nextWorkspaceId: string | null | undefined
1246-
): void {
1247-
const from = useAgentCanvasStore.getState().workspaceKey;
1248-
const to = normalizeAgentWorkspaceKey(nextWorkspaceId);
1243+
export function switchAgentCanvasScope(nextScopeId: string | null | undefined): void {
1244+
const from = useAgentCanvasStore.getState().scopeKey;
1245+
const to = normalizeAgentScopeKey(nextScopeId);
12491246

12501247
if (from === to) return;
12511248

1252-
const rawNext = agentWorkspaceSnapshots.get(to);
1249+
const rawNext = agentScopeSnapshots.get(to);
12531250
const nextSnapshotClone = rawNext ? structuredClone(rawNext) : null;
12541251

12551252
if (from !== undefined) {
@@ -1259,7 +1256,7 @@ export function switchAgentCanvasWorkspace(
12591256

12601257
if (nextSnapshotClone) {
12611258
useAgentCanvasStore.setState({
1262-
workspaceKey: to,
1259+
scopeKey: to,
12631260
primaryGroup: nextSnapshotClone.primaryGroup,
12641261
secondaryGroup: nextSnapshotClone.secondaryGroup,
12651262
tertiaryGroup: nextSnapshotClone.tertiaryGroup,
@@ -1277,14 +1274,6 @@ export function switchAgentCanvasWorkspace(
12771274

12781275
}
12791276

1280-
/** Drop cached canvas for a closed workspace (does not touch the live canvas unless user switches back). */
1281-
export function removeAgentCanvasSnapshot(workspaceId: string): void {
1282-
const key = normalizeAgentWorkspaceKey(workspaceId);
1283-
agentWorkspaceSnapshots.delete(key);
1284-
const idx = agentSnapshotLruOrder.indexOf(key);
1285-
if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1);
1286-
}
1287-
12881277
const selectWholeCanvasStore = (state: CanvasStore) => state;
12891278

12901279
export function useCanvasStore(): CanvasStore;
@@ -1340,8 +1329,8 @@ export const useDragging = () => {
13401329
}));
13411330
};
13421331

1343-
/** Includes suspended workspace snapshots so unmounting a view does not discard its document. */
1332+
/** Includes suspended scope snapshots so unmounting a view does not discard its document. */
13441333
export function hasRetainedCanvasTab(tabId: string): boolean {
1345-
const states = [useAgentCanvasStore.getState(), useGitCanvasStore.getState(), useBottomTerminalCanvasStore.getState(), ...agentWorkspaceSnapshots.values()];
1334+
const states = [useAgentCanvasStore.getState(), useGitCanvasStore.getState(), useBottomTerminalCanvasStore.getState(), ...agentScopeSnapshots.values()];
13461335
return states.some(state => [state.primaryGroup, state.secondaryGroup, state.tertiaryGroup].some(group => group.tabs.some(tab => tab.id === tabId)));
13471336
}

‎src/web-ui/src/app/components/panels/content-canvas/stores/index.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export {
1212
useActiveTabId,
1313
useLayout,
1414
useDragging,
15-
switchAgentCanvasWorkspace,
16-
removeAgentCanvasSnapshot,
15+
switchAgentCanvasScope,
1716
clearAgentCanvasForPeerSwitch,
1817
} from './canvasStore';

0 commit comments

Comments
 (0)