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
19 changes: 19 additions & 0 deletions src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,25 @@ describe('SceneBar overflow navigation', () => {
expect(sceneHarness.activateScene).toHaveBeenCalledWith('settings');
});

it('activates a retained background tab from the idle shell state', () => {
const previousTabs = sceneHarness.state.openTabs;
sceneHarness.state.activeTabId = null;
sceneHarness.state.openTabs = [
{ id: 'settings', lastUsed: 1 },
{ id: 'terminal', lastUsed: 2 },
];
try {
renderSceneBar();

const settings = container.querySelector<HTMLButtonElement>('[role="tab"][data-openbitfun-value="settings"]')!;
act(() => settings.click());

expect(sceneHarness.activateScene).toHaveBeenCalledWith('settings');
} finally {
sceneHarness.state.openTabs = previousTabs;
}
});

it('exposes overflow controls and translates a vertical wheel into horizontal movement', () => {
renderSceneBar();
const region = container.querySelector<HTMLElement>('[data-openbitfun-component="scene-bar"][data-openbitfun-part="tabs"]')!;
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/src/app/components/SceneBar/SceneBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,15 @@ const SceneBar: React.FC<SceneBarProps> = ({
data-scene-tab-id={tabId}
data-pending={pendingTabId === tabId || undefined}
data-canvas-drop-position={activeCanvasDrop?.target?.tabId === tabId ? activeCanvasDrop.target.placement : undefined}
onClick={event => {
// TabGroup falls back to its first item when the controlled
// value is empty. In the shell idle state that fallback must
// not swallow clicks on a retained background tab.
if (selectedTabId !== null) return;
if (event.target instanceof Element
&& event.target.closest('[data-scene-bar-part="closeTab"]')) return;
activateScene(tabId);
}}
onContextMenu={event => handleContextMenu(event, tabId)}
onPointerEnter={event => {
const item = event.currentTarget;
Expand Down
18 changes: 18 additions & 0 deletions src/web-ui/src/app/scenes/SceneViewport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,24 @@ describe('SceneViewport transitions', () => {
expect(container.querySelector('[role="tab"]')).toBeNull();
});

it('renders the welcome surface when only a background scene tab remains', () => {
sceneHarness.state = {
openTabs: [{ id: 'settings', lastUsed: 1 }],
activeTabId: null,
navigationMotion: 'instant',
navigationSequence: 1,
};

act(() => root.render(<SceneViewport />));

expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['__empty-scene__']);
expect(container.querySelector('[data-testid="welcome-scene"]')).not.toBeNull();
expect(container.querySelector('[data-scene-id="settings"]')).not.toBeNull();
expect(container.querySelector('[data-scene-id="settings"]')?.classList.contains(
'openbitfun-scene-viewport__scene--visible',
)).toBe(false);
});

it('shares one mounted Session host across workspace tab identities', () => {
sceneHarness.state.openTabs = [
{ id: 'session:workspace-a', lastUsed: 1 },
Expand Down
5 changes: 3 additions & 2 deletions src/web-ui/src/app/scenes/SceneViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* session tabs share one Session host backed by the authoritative selection;
* their resource identity is independent of the host's mounting lifetime.
*
* When no tabs are open, the viewport renders WelcomeScene as a shell-owned
* landing surface rather than manufacturing a tab for it.
* When no scene is selected, the viewport renders WelcomeScene as a shell-owned
* landing surface rather than manufacturing a tab for it. Background tabs may
* remain open while the shell is idle.
*/
import { lazyWithRecovery } from '@/shared/utils/lazyWithRecovery';

Expand Down
22 changes: 20 additions & 2 deletions src/web-ui/src/app/stores/sceneStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ describe('sceneStore transition snapshots', () => {
expect(state.navCursor).toBe(-1);
});

it('keeps the session tab first while allowing it to close back to another scene', () => {
it('keeps the session tab first while entering welcome when it is the active last session', () => {
useSceneStore.getState().openScene('settings');
useSceneStore.getState().openSessionScene(sessionTarget);

Expand All @@ -323,10 +323,28 @@ describe('sceneStore transition snapshots', () => {

useSceneStore.getState().closeScene(sessionTabId);

const state = useSceneStore.getState();
expect(state.openTabs.map(tab => tab.id)).toEqual(['settings']);
expect(state.activeTabId).toBeNull();
expect(state.paneSelections.primary).toEqual({ kind: 'empty' });
expect(state.navHistory).toEqual([]);
expect(state.navCursor).toBe(-1);

useSceneStore.getState().activateScene('settings');
expect(useSceneStore.getState().activeTabId).toBe('settings');
});

it('keeps the settings scene selected when closing a background session', () => {
useSceneStore.getState().openSessionScene(sessionTarget);
useSceneStore.getState().openScene('settings');

useSceneStore.getState().closeScene(sessionTabId);

const state = useSceneStore.getState();
expect(state.openTabs.map(tab => tab.id)).toEqual(['settings']);
expect(state.activeTabId).toBe('settings');
expect(state.navHistory).not.toContain(sessionTabId);
expect(state.paneSelections.primary).toEqual({ kind: 'scene', tabId: 'settings' });
expect(state.navHistory).toEqual(['settings']);
});

it('preserves close fallback and history navigation across many open tabs', () => {
Expand Down
27 changes: 24 additions & 3 deletions src/web-ui/src/app/stores/sceneStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,34 @@ export const useSceneStore = create<SceneState>((set, get) => ({
const contentId = openTabs.find(tab => tab.id === id)?.contentId;
return navigateWorkbench(current => {
if (!current.openTabs.some(tab => tab.id === id)) return null;
let panes = reconcilePaneState({ ...current, openTabs: current.openTabs.filter(tab => tab.id !== id) });
if (panes.paneSelections[panes.focusedPane].kind === 'empty'
const closingActiveSession = isSessionSceneId(id) && id === current.activeTabId;
// A background session close must not steal the currently viewed scene.
// Only the active session can leave the shell idle, and only when no
// session remains on the rendered surface.
const hasRemainingSurfaceSession = current.openTabs.some(tab => tab.id !== id
&& tab.session?.surfaceId === getActiveSurfaceId());
const enterIdleState = closingActiveSession && !hasRemainingSurfaceSession;
const remainingTabs = current.openTabs.filter(tab => tab.id !== id);
const nextState = enterIdleState
? {
...current,
openTabs: remainingTabs,
paneSelections: {
...current.paneSelections,
[current.focusedPane]: vacantSelection(current.focusedPane, current.canvasPane),
},
}
: { ...current, openTabs: remainingTabs };
let panes = reconcilePaneState(nextState);
if (!enterIdleState && panes.paneSelections[panes.focusedPane].kind === 'empty'
&& selectedScene(panes.paneSelections[otherPane(panes.focusedPane)])) {
panes = { ...panes, focusedPane: otherPane(panes.focusedPane) };
}
const history = enterIdleState
? { navHistory: [], navCursor: -1 }
: removeFromHistory(current.navHistory, current.navCursor, id, focusedScene(panes));
return { panes, reveal: id === current.activeTabId ? [panes.focusedPane] : [],
history: removeFromHistory(current.navHistory, current.navCursor, id, focusedScene(panes)) };
history };
}, { guardSettings: false, onCommitted: () => {
if (contentId) useContentResourceStore.getState().remove(contentId);
} }).then(activated => {
Expand Down
Loading