Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -170,15 +170,15 @@ 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();
act(() => {
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
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),
Expand All @@ -79,6 +80,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
);
const currentWorkspaceId = currentWorkspace?.id;
const lastSyncedBtwTabIdRef = useRef<string | null>(null);
const lastCanvasScopeKeyRef = useRef(canvasScopeKey);
// Initialize hooks
const { handleCloseWithDirtyCheck, handleCloseAllWithDirtyCheck } = useTabLifecycle({
mode,
Expand All @@ -93,6 +95,9 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
});

useEffect(() => {
const canvasScopeChanged = lastCanvasScopeKeyRef.current !== canvasScopeKey;
lastCanvasScopeKeyRef.current = canvasScopeKey;

if (mode !== 'agent' || !activeBtwSessionTab?.id || !activeBtwSessionData?.parentSessionId) {
lastSyncedBtwTabIdRef.current = null;
return;
Expand All @@ -102,6 +107,14 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
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
Expand All @@ -127,6 +140,7 @@ export const ContentCanvas: React.FC<ContentCanvasProps> = ({
activeBtwSessionData?.workspaceId,
activeBtwSessionData?.workspacePath,
activeBtwSessionTab?.id,
canvasScopeKey,
currentWorkspaceId,
mode,
workspacePath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CanvasStoreModeContext,
clearAgentCanvasForPeerSwitch,
switchAgentCanvasWorkspace,
switchAgentCanvasScope,
useAgentCanvasStore,
useBottomTerminalCanvasStore,
useCanvasStore,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(<Hosts />));
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(<Hosts />);
});
await act(async () => {
switchAgentCanvasWorkspace('workspace-b', 'workspace-a');
switchAgentCanvasScope('session-a');
root.render(<Hosts />);
});
// 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(<Hosts />));
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 () => {
Expand All @@ -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(<Hosts />));
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -159,7 +159,7 @@ type CanvasStore = CanvasStoreState & CanvasStoreActions;
// ==================== Initial State ====================

const initialState: CanvasStoreState = {
workspaceKey: undefined,
scopeKey: undefined,
primaryGroup: createEditorGroupState(),
secondaryGroup: createEditorGroupState(),
tertiaryGroup: createEditorGroupState(),
Expand Down Expand Up @@ -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<string, CanvasStoreState>();
const AGENT_CANVAS_SNAPSHOT_MAX = 24;
const agentScopeSnapshots = new Map<string, CanvasStoreState>();
const agentSnapshotLruOrder: string[] = [];

function normalizeAgentWorkspaceKey(id: string | null | undefined): string {
function normalizeAgentScopeKey(id: string | null | undefined): string {
return id ?? '__none__';
}

Expand All @@ -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(),
Expand All @@ -1227,29 +1227,26 @@ 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();
useBottomTerminalCanvasStore.getState().reset();
}

/**
* 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) {
Expand All @@ -1259,7 +1256,7 @@ export function switchAgentCanvasWorkspace(

if (nextSnapshotClone) {
useAgentCanvasStore.setState({
workspaceKey: to,
scopeKey: to,
primaryGroup: nextSnapshotClone.primaryGroup,
secondaryGroup: nextSnapshotClone.secondaryGroup,
tertiaryGroup: nextSnapshotClone.tertiaryGroup,
Expand All @@ -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;
Expand Down Expand Up @@ -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)));
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export {
useActiveTabId,
useLayout,
useDragging,
switchAgentCanvasWorkspace,
removeAgentCanvasSnapshot,
switchAgentCanvasScope,
clearAgentCanvasForPeerSwitch,
} from './canvasStore';
Loading
Loading