feat(web-shell): expose external split controls (#6523)

* feat(web-shell): expose external split controls

* fix(web-shell): tighten split controlled behavior

* fix(web-shell): address split control review

* fix(web-shell): sync controlled split exit

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-07-08 20:02:10 +08:00 committed by GitHub
parent 1f92787aa0
commit b2b02d27ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 604 additions and 60 deletions

View file

@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { act, createRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { WebShellApi } from './App';
type StreamingState = 'idle' | 'responding';
@ -363,7 +364,7 @@ vi.doMock('./components/SplitView', async () => {
return {
SplitView: (props: {
onExit?: () => void;
initialSessionIds?: string[];
sessionIds?: string[];
onPanesChange?: (ids: string[]) => void;
}) =>
React.createElement(
@ -373,7 +374,7 @@ vi.doMock('./components/SplitView', async () => {
React.createElement(
'span',
{ 'data-testid': 'split-initial' },
(props.initialSessionIds ?? []).join(','),
(props.sessionIds ?? []).join(','),
),
// Simulate the real SplitView reporting its live pane set up to the App.
React.createElement(
@ -443,6 +444,7 @@ const mounted: Array<{ root: Root; container: HTMLElement }> = [];
function renderApp(props: React.ComponentProps<typeof App> = {}): {
container: HTMLElement;
rerender: (nextProps?: React.ComponentProps<typeof App>) => void;
unmount: () => void;
} {
const container = document.createElement('div');
document.body.appendChild(container);
@ -453,8 +455,15 @@ function renderApp(props: React.ComponentProps<typeof App> = {}): {
});
};
doRender(props);
mounted.push({ root, container });
return { container, rerender: doRender };
const entry = { root, container };
mounted.push(entry);
const unmount = () => {
const index = mounted.indexOf(entry);
if (index >= 0) mounted.splice(index, 1);
act(() => root.unmount());
container.remove();
};
return { container, rerender: doRender, unmount };
}
async function flush(): Promise<void> {
@ -1093,6 +1102,231 @@ describe('App session callbacks', () => {
expect(messages?.closest('[aria-hidden="true"]')).not.toBeNull();
});
it('syncs the split view from external session ids without the sidebar', async () => {
const { container, rerender } = renderApp({
sidebar: false,
splitSessionIds: ['s1'],
});
await flush();
expect(container.querySelector('[data-testid="sidebar"]')).toBeNull();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1');
rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] });
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2');
rerender({ sidebar: false, splitSessionIds: [] });
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] });
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2');
});
it('dedupes and caps external split session ids', async () => {
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's1', 's2', 's3', 's4', 's5', 's6', 's7'],
});
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2,s3,s4,s5,s6');
});
it('does not reopen controlled split view when the same ids get a new array reference', async () => {
const { container, rerender } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's2'],
});
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container
.querySelector('[data-testid="inline-panel"]')
?.getAttribute('aria-label'),
).toBe('Session Overview');
rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] });
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container
.querySelector('[data-testid="inline-panel"]')
?.getAttribute('aria-label'),
).toBe('Session Overview');
});
it('notifies external callers when split session ids change inside WebShell', async () => {
const onSplitSessionIdsChange = vi.fn();
const { container, rerender } = renderApp({
sidebar: false,
splitSessionIds: ['s1'],
onSplitSessionIdsChange,
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-report-panes"]')
?.click();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']);
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1');
rerender({
sidebar: false,
splitSessionIds: ['s1', 's2', 's3'],
onSplitSessionIdsChange,
});
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2,s3');
});
it('notifies external callers when uncontrolled split session ids change', async () => {
const onSplitSessionIdsChange = vi.fn();
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({
sidebar: false,
onSplitSessionIdsChange,
shellRef,
});
await flush();
await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-report-panes"]')
?.click();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']);
});
it('opens the split view from the external shell ref like the sidebar button', async () => {
let shellApi: WebShellApi | null = null;
const { container } = renderApp({
sidebar: false,
shellRef: (api) => {
shellApi = api;
},
});
await flush();
expect(container.querySelector('[data-testid="sidebar"]')).toBeNull();
await act(async () => {
shellApi?.openSplitView();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('session-1');
});
it('requests controlled split ids from the external shell ref', async () => {
const onSplitSessionIdsChange = vi.fn();
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({
sidebar: false,
splitSessionIds: [],
onSplitSessionIdsChange,
shellRef,
});
await flush();
await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['session-1']);
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});
it('assigns and clears the external shell object ref', async () => {
const shellRef = createRef<WebShellApi>();
const { unmount } = renderApp({
sidebar: false,
shellRef,
});
await flush();
expect(shellRef.current).not.toBeNull();
unmount();
expect(shellRef.current).toBeNull();
});
it('opens the Session Overview from the external shell ref like the sidebar button', async () => {
let shellApi: WebShellApi | null = null;
const { container } = renderApp({
sidebar: false,
shellRef: (api) => {
shellApi = api;
},
});
await flush();
expect(container.querySelector('[data-testid="sidebar"]')).toBeNull();
await act(async () => {
shellApi?.openSessionOverview();
await Promise.resolve();
});
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});
it('returns to the Session Overview when leaving the split view', async () => {
const { container } = renderApp();
await flush();
@ -1122,6 +1356,33 @@ describe('App session callbacks', () => {
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});
it('notifies controlled callers when leaving the split view', async () => {
const onSplitSessionIdsChange = vi.fn();
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's2'],
onSplitSessionIdsChange,
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith([]);
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container
.querySelector('[data-testid="inline-panel"]')
?.getAttribute('aria-label'),
).toBe('Session Overview');
});
it('preserves the pane set when leaving the split view and reopening it', async () => {
const { container } = renderApp();
await flush();
@ -1148,7 +1409,9 @@ describe('App session callbacks', () => {
?.click();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="split-view-page"]')).toBeNull();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
// …and reopen it from the toolbar. The reported panes must be restored, not
// reset to empty / the current session (the regression this guards).
@ -1178,6 +1441,23 @@ describe('App session callbacks', () => {
}
});
it('lets controlled split session ids take precedence over a ?split= URL', async () => {
window.history.pushState({}, '', '/?split=s1,s2');
try {
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s3'],
});
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s3');
expect(window.location.search).toBe('');
} finally {
window.history.pushState({}, '', '/');
}
});
it('seeds the split from a ?split= URL, deduping and capping the explicit selection', async () => {
// Duplicates and more than MAX_SPLIT_PANES (6) ids drive the explicit-
// selection branch of openSplitView (dedupe + cap + replace), distinct from
@ -1306,6 +1586,49 @@ describe('App session callbacks', () => {
).toBeNull();
});
it('notifies controlled callers when a screen shrink closes the split view', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('min-width')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
const onSplitSessionIdsChange = vi.fn();
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's2'],
onSplitSessionIdsChange,
});
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith([]);
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});
it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => {
// Drive isLargeScreen through a controllable media query: open the panel on
// a large screen, then flip below the breakpoint and confirm it closes.

View file

@ -349,6 +349,13 @@ export type SessionChangeEvent =
| { type: 'submit'; sessionId: string; prompt: string; queued: boolean }
| { type: 'turn_complete'; sessionId: string; error?: Error };
export interface WebShellApi {
/** Open the in-window split view, matching the built-in sidebar button. */
openSplitView: () => void;
/** Open the Session Overview panel, matching the built-in sidebar button. */
openSessionOverview: () => void;
}
export interface WebShellProps {
/** Called whenever the attached daemon session id changes. */
onSessionIdChange?: (sessionId: string | undefined) => void;
@ -368,6 +375,12 @@ export interface WebShellProps {
chatMaxWidth?: number;
/** Optional workspace sidebar. Disabled by default. */
sidebar?: boolean | WebShellSidebarOptions;
/** Session ids to control the split view; an empty array closes it. */
splitSessionIds?: readonly string[];
/** Called when the split pane list changes from inside WebShell. */
onSplitSessionIdsChange?: (sessionIds: string[]) => void;
/** Imperative handle for externally opening WebShell surfaces. */
shellRef?: React.Ref<WebShellApi>;
/** Built-in composer toolbar actions to show. Defaults to all actions. */
composerToolbarActions?: readonly ComposerToolbarAction[];
/** Called when connection status changes (idle/connecting/connected/disconnected/error). */
@ -564,6 +577,25 @@ function assignComposerRef(
(ref as React.MutableRefObject<WebShellComposerApi | null>).current = value;
}
function assignShellRef(
ref: React.Ref<WebShellApi> | undefined,
value: WebShellApi | null,
): void {
if (!ref) return;
if (typeof ref === 'function') {
ref(value);
return;
}
(ref as React.MutableRefObject<WebShellApi | null>).current = value;
}
function areSessionIdsEqual(
a: readonly string[],
b: readonly string[],
): boolean {
return a.length === b.length && a.every((id, index) => id === b[index]);
}
function getInitialLanguage(): WebShellLanguage {
if (typeof window === 'undefined') return 'en';
const params = new URLSearchParams(window.location.search);
@ -804,6 +836,9 @@ export function App({
renderFooter,
chatMaxWidth,
sidebar,
splitSessionIds: externalSplitSessionIds,
onSplitSessionIdsChange,
shellRef,
composerToolbarActions,
compactThinking = false,
collapseCompletedTurns = true,
@ -1250,7 +1285,7 @@ export function App({
// is the live pane set — SplitView mirrors add/remove back into it via
// onPanesChange — so it must be preserved across entries, not blindly reset.
const openSplitView = useCallback(
(sessionIds?: string[]) => {
(sessionIds?: readonly string[]) => {
setActivePanel(null);
setSplitSessionIds((prev) => {
// An explicit selection (the overview, or a `?split=` URL) replaces the
@ -1269,21 +1304,98 @@ export function App({
},
[connection.sessionId],
);
const externalSplitSignature = useMemo(() => {
const requested = Array.from(
new Set((externalSplitSessionIds ?? []).filter(Boolean)),
).slice(0, MAX_SPLIT_PANES);
return requested.join('\0');
}, [externalSplitSessionIds]);
const externalSplitControlled = externalSplitSessionIds !== undefined;
const onSplitSessionIdsChangeRef = useRef(onSplitSessionIdsChange);
onSplitSessionIdsChangeRef.current = onSplitSessionIdsChange;
const requestOpenSplitView = useCallback(() => {
if (!externalSplitControlled) {
openSplitView();
return;
}
const requested =
splitSessionIds.length > 0
? splitSessionIds
: connection.sessionId
? [connection.sessionId]
: [];
onSplitSessionIdsChangeRef.current?.(requested);
}, [
connection.sessionId,
externalSplitControlled,
openSplitView,
splitSessionIds,
]);
const shellApi = useMemo<WebShellApi>(
() => ({
openSplitView: () => requestOpenSplitView(),
openSessionOverview: () => openPanel('sessions'),
}),
[openPanel, requestOpenSplitView],
);
useEffect(() => {
assignShellRef(shellRef, shellApi);
}, [shellApi, shellRef]);
useEffect(
() => () => {
assignShellRef(shellRef, null);
},
[shellRef],
);
useEffect(() => {
if (!externalSplitControlled) return;
const requested = externalSplitSignature
? externalSplitSignature.split('\0')
: [];
setSplitSessionIds((prev) =>
areSessionIdsEqual(prev, requested) ? prev : requested,
);
if (requested.length > 0) {
setActivePanel((prev) => (prev === null ? prev : null));
setMainView((prev) => (prev === 'split' ? prev : 'split'));
} else {
setMainView((prev) => (prev === 'split' ? 'chat' : prev));
}
}, [externalSplitControlled, externalSplitSignature]);
const handleSplitPanesChange = useCallback(
(sessionIds: string[]) => {
if (!externalSplitControlled) {
setSplitSessionIds(sessionIds);
}
onSplitSessionIdsChangeRef.current?.(sessionIds);
},
[externalSplitControlled],
);
const notifyControlledSplitClose = useCallback(() => {
if (externalSplitControlled) {
onSplitSessionIdsChangeRef.current?.([]);
}
}, [externalSplitControlled]);
// Stable so SplitView's onExit-dependent effect (auto-exit on last pane
// close) doesn't re-fire on every App re-render. Back from the split returns
// to the Session Overview — the hub the split is launched from.
const handleSplitExit = useCallback(() => openPanel('sessions'), [openPanel]);
const handleSplitExit = useCallback(() => {
notifyControlledSplitClose();
openPanel('sessions');
}, [notifyControlledSplitClose, openPanel]);
// A `?split=a,b` URL (opened in a new tab from the overview) enters the split
// view with those sessions on load. Consume the param once so a later reload
// or exit doesn't force the split back on.
useEffect(() => {
const ids = parseSplitSessionIds(window.location.search);
if (ids.length === 0) return;
openSplitView(ids);
const url = new URL(window.location.href);
url.searchParams.delete('split');
window.history.replaceState(null, '', url);
}, [openSplitView]);
if (!externalSplitControlled) {
openSplitView(ids);
}
}, [externalSplitControlled, openSplitView]);
// If the viewport shrinks below the large-screen breakpoint, close the Session
// Overview panel and the split view — both are large-screen-only surfaces
// whose entry points are hidden on small screens, so leaving them up would
@ -1296,10 +1408,11 @@ export function App({
setActivePanel(null);
}
if (!isLargeScreen && mainView === 'split') {
notifyControlledSplitClose();
setMainView('chat');
focusComposerAfterSplitCloseRef.current = true;
}
}, [isLargeScreen, activePanel, mainView]);
}, [isLargeScreen, activePanel, mainView, notifyControlledSplitClose]);
// Land focus on the composer after a shrink-driven split close so keyboard
// users aren't dropped onto <body> — but not when the chat now shows an
// approval overlay (it owns the keyboard) or a panel (its Back self-focuses).
@ -4760,12 +4873,12 @@ export function App({
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<SplitView
initialSessionIds={splitSessionIds}
sessionIds={splitSessionIds}
// Mirror live pane add/remove back up so switching away
// and re-entering restores the same panes. Pass the
// setter directly — a fresh arrow each render would loop
// SplitView's reporting effect.
onPanesChange={setSplitSessionIds}
// and re-entering restores the same panes. Keep this
// callback stable to avoid looping SplitView's reporting
// effect.
onPanesChange={handleSplitPanesChange}
// Refresh the "add pane" picker when the session list
// changes elsewhere, matching the sidebar.
sessionListReloadToken={sessionListReloadToken}

View file

@ -122,7 +122,7 @@ function openPicker(): void {
describe('SplitView', () => {
it('renders one pane per initial session, each under its own provider', () => {
render({ initialSessionIds: ['s1', 's2'] });
render({ sessionIds: ['s1', 's2'] });
expect(panes()).toHaveLength(2);
expect(titles()).toEqual(['One', 'Two']);
const providers = container!.querySelectorAll('[data-session]');
@ -137,18 +137,72 @@ describe('SplitView', () => {
expect(s2ClientId).toBe(`split-pane:${nonce}:s2`);
});
it('seeds with the current session when no initial sessions are given', () => {
render({ initialSessionIds: [] });
it('seeds with the current session when no session ids are given', () => {
render();
expect(titles()).toEqual(['Three']);
});
it('dedupes initial sessions', () => {
render({ initialSessionIds: ['s1', 's1', 's2'] });
render({ sessionIds: ['s1', 's1', 's2'] });
expect(titles()).toEqual(['One', 'Two']);
});
it('syncs panes when session ids change after mount', () => {
render({ sessionIds: ['s1'] });
expect(titles()).toEqual(['One']);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView onExit={() => {}} sessionIds={['s1', 's2']} />
</I18nProvider>,
),
);
expect(titles()).toEqual(['One', 'Two']);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView onExit={() => {}} sessionIds={[]} />
</I18nProvider>,
),
);
expect(panes()).toHaveLength(0);
});
it('requests pane changes without mutating local panes when controlled', () => {
const onPanesChange = vi.fn();
render({ sessionIds: ['s1'], onPanesChange });
openPicker();
const options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(onPanesChange).toHaveBeenCalledWith(['s1', 's2']);
expect(titles()).toEqual(['One']);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView
onExit={() => {}}
sessionIds={['s1', 's2']}
onPanesChange={onPanesChange}
/>
</I18nProvider>,
),
);
expect(titles()).toEqual(['One', 'Two']);
const close = container!.querySelector('[data-testid="pane-close"]');
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onPanesChange).toHaveBeenCalledWith(['s2']);
expect(titles()).toEqual(['One', 'Two']);
});
it('adds a pane from the picker', () => {
render({ initialSessionIds: ['s1'] });
render();
expect(panes()).toHaveLength(1);
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
@ -156,7 +210,7 @@ describe('SplitView', () => {
act(() =>
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
// Picker lists sessions not already shown (s2, s3, s4).
// Picker lists sessions not already shown (s1, s2, s4).
const options = container!.querySelectorAll('[role="option"] button');
expect(options).toHaveLength(3);
act(() =>
@ -166,7 +220,7 @@ describe('SplitView', () => {
});
it('closes the picker on Escape', () => {
render({ initialSessionIds: ['s1'] });
render({ sessionIds: ['s1'] });
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
@ -184,7 +238,7 @@ describe('SplitView', () => {
});
it('closes the picker on a click outside it', () => {
render({ initialSessionIds: ['s1'] });
render({ sessionIds: ['s1'] });
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
@ -203,7 +257,7 @@ describe('SplitView', () => {
});
it('keeps the picker open on a click inside it', () => {
render({ initialSessionIds: ['s1'] });
render({ sessionIds: ['s1'] });
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
@ -221,17 +275,24 @@ describe('SplitView', () => {
});
it('removes a pane via its close button', () => {
render({ initialSessionIds: ['s1', 's2'] });
render();
openPicker();
const options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(panes()).toHaveLength(2);
const closes = container!.querySelectorAll('[data-testid="pane-close"]');
act(() =>
closes[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(titles()).toEqual(['Two']);
expect(titles()).toEqual(['One']);
});
it('auto-exits to the overview when the last pane is closed', () => {
const onExit = vi.fn();
render({ initialSessionIds: ['s1'], onExit });
render({ onExit });
expect(onExit).not.toHaveBeenCalled();
const close = container!.querySelector('[data-testid="pane-close"]');
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
@ -240,7 +301,7 @@ describe('SplitView', () => {
it('exits via the back button', () => {
const onExit = vi.fn();
render({ initialSessionIds: ['s1'], onExit });
render({ sessionIds: ['s1'], onExit });
// The back button is the first toolbar button (aria-label from common.back).
const back = container!.querySelector('header button') as HTMLButtonElement;
act(() => back.dispatchEvent(new MouseEvent('click', { bubbles: true })));
@ -253,7 +314,7 @@ describe('SplitView', () => {
workspaceCwd: '/w',
displayName: `Pane ${i}`,
}));
render({ initialSessionIds: sessionsState.map((s) => s.sessionId) });
render({ sessionIds: sessionsState.map((s) => s.sessionId) });
// Eight requested, but only six live panes mount.
expect(panes()).toHaveLength(6);
});
@ -263,7 +324,7 @@ describe('SplitView', () => {
{ sessionId: 's1', workspaceCwd: '/w', displayName: 'BOOM' },
{ sessionId: 's2', workspaceCwd: '/w', displayName: 'Two' },
];
render({ initialSessionIds: ['s1', 's2'] });
render({ sessionIds: ['s1', 's2'] });
// The crashing pane shows its error fallback; the healthy pane still renders.
expect(container!.textContent).toContain('This session pane hit an error');
expect(panes()).toHaveLength(1);
@ -271,7 +332,7 @@ describe('SplitView', () => {
});
it('reloads the session list when the picker opens (never a stale list)', () => {
render({ initialSessionIds: ['s1'] });
render({ sessionIds: ['s1'] });
// `useSessions` only fetches on mount; nothing reloads until the user acts.
expect(reloadMock).not.toHaveBeenCalled();
const addButton = container!.querySelector(
@ -284,7 +345,7 @@ describe('SplitView', () => {
});
it('renders the refreshed session list on reopen — not the entry snapshot', () => {
render({ initialSessionIds: ['s1'] });
render({ sessionIds: ['s1'] });
// First open: the picker offers the sessions present at entry.
openPicker();
expect(pickerOptions()).toEqual(['Two', 'Three', 'Four']);
@ -305,7 +366,7 @@ describe('SplitView', () => {
});
it('reloads the picker list when the parent bumps the reload token', () => {
render({ initialSessionIds: ['s1'], sessionListReloadToken: 0 });
render({ sessionIds: ['s1'], sessionListReloadToken: 0 });
// The initial token is not a change, so it does not trigger a reload.
expect(reloadMock).not.toHaveBeenCalled();
act(() =>
@ -313,7 +374,7 @@ describe('SplitView', () => {
<I18nProvider language="en">
<SplitView
onExit={() => {}}
initialSessionIds={['s1']}
sessionIds={['s1']}
sessionListReloadToken={1}
/>
</I18nProvider>,
@ -324,9 +385,9 @@ describe('SplitView', () => {
it('mirrors the live pane set up to the parent as panes change', () => {
const onPanesChange = vi.fn();
render({ initialSessionIds: ['s1'], onPanesChange });
render({ onPanesChange });
// Reported on mount so the parent's seed reflects the actual panes…
expect(onPanesChange).toHaveBeenLastCalledWith(['s1']);
expect(onPanesChange).toHaveBeenLastCalledWith(['s3']);
// …and after every add (so switching away and back restores it).
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
@ -338,10 +399,10 @@ describe('SplitView', () => {
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(onPanesChange).toHaveBeenLastCalledWith(['s1', 's2']);
expect(onPanesChange).toHaveBeenLastCalledWith(['s3', 's1']);
// …and after every remove.
const close = container!.querySelector('[data-testid="pane-close"]');
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onPanesChange).toHaveBeenLastCalledWith(['s2']);
expect(onPanesChange).toHaveBeenLastCalledWith(['s1']);
});
});

View file

@ -23,8 +23,8 @@ import styles from './SplitView.module.css';
const MAX_PANES = MAX_SPLIT_PANES;
export interface SplitViewProps {
/** Sessions to open initially (e.g. the selection from the overview). */
initialSessionIds?: string[];
/** Sessions to show in the split view. */
sessionIds?: string[];
/**
* Report the live pane set (after every add / remove) up to the parent so it
* survives this view unmounting. Switching away from the split and back must
@ -52,7 +52,7 @@ export interface SplitViewProps {
* fight over which session an approval or Enter belongs to.
*/
export function SplitView({
initialSessionIds,
sessionIds,
onPanesChange,
onExit,
onError,
@ -72,10 +72,18 @@ export function SplitView({
? { view: 'organized' as const, group: 'all' }
: {}),
});
const sessionIdsControlled = sessionIds !== undefined;
const normalizedSessionIds = useMemo(
() =>
Array.from(new Set((sessionIds ?? []).filter(Boolean))).slice(
0,
MAX_PANES,
),
[sessionIds],
);
const [paneIds, setPaneIds] = useState<string[]>(() => {
const seed = Array.from(new Set((initialSessionIds ?? []).filter(Boolean)));
if (seed.length > 0) return seed.slice(0, MAX_PANES);
if (normalizedSessionIds.length > 0) return normalizedSessionIds;
return currentSessionId ? [currentSessionId] : [];
});
const [pickerOpen, setPickerOpen] = useState(false);
@ -89,6 +97,18 @@ export function SplitView({
: Math.random().toString(36).slice(2),
);
useEffect(() => {
if (!sessionIdsControlled) return;
setPaneIds((prev) =>
prev.length === normalizedSessionIds.length &&
prev.every((id, index) => id === normalizedSessionIds[index])
? prev
: normalizedSessionIds,
);
}, [normalizedSessionIds, sessionIdsControlled]);
const paneIdsRef = useRef(paneIds);
paneIdsRef.current = paneIds;
// Dismiss the "add session" picker on Escape or a click outside it.
useEffect(() => {
if (!pickerOpen) return;
@ -149,14 +169,26 @@ export function SplitView({
return map;
}, [sessions]);
const addPane = useCallback((sessionId: string) => {
setPaneIds((prev) =>
prev.includes(sessionId) || prev.length >= MAX_PANES
? prev
: [...prev, sessionId],
);
setPickerOpen(false);
}, []);
const addPane = useCallback(
(sessionId: string) => {
const currentPaneIds = paneIdsRef.current;
if (
currentPaneIds.includes(sessionId) ||
currentPaneIds.length >= MAX_PANES
) {
setPickerOpen(false);
return;
}
const next = [...currentPaneIds, sessionId];
if (sessionIdsControlled) {
onPanesChange?.(next);
} else {
setPaneIds(next);
}
setPickerOpen(false);
},
[onPanesChange, sessionIdsControlled],
);
// Closing the last pane is a natural "I'm done" gesture — return to the
// overview instead of stranding the user on an empty split. Guarded so an
@ -172,14 +204,24 @@ export function SplitView({
// Mirror the live pane set up to the parent so it outlives this component
// unmounting when the user switches views. On re-entry the parent reseeds
// `initialSessionIds` from it, restoring the exact panes instead of clearing.
// `sessionIds` from it, restoring the exact panes instead of clearing.
useEffect(() => {
onPanesChange?.(paneIds);
}, [paneIds, onPanesChange]);
if (!sessionIdsControlled) onPanesChange?.(paneIds);
}, [paneIds, onPanesChange, sessionIdsControlled]);
const removePane = useCallback((sessionId: string) => {
setPaneIds((prev) => prev.filter((id) => id !== sessionId));
}, []);
const removePane = useCallback(
(sessionId: string) => {
const currentPaneIds = paneIdsRef.current;
if (!currentPaneIds.includes(sessionId)) return;
const next = currentPaneIds.filter((id) => id !== sessionId);
if (sessionIdsControlled) {
onPanesChange?.(next);
} else {
setPaneIds(next);
}
},
[onPanesChange, sessionIdsControlled],
);
const available = useMemo(
() => sessions.filter((session) => !paneIds.includes(session.sessionId)),

View file

@ -1,5 +1,10 @@
export { App as WebShell } from './App';
export type { WebShellProps, BugReportInfo, SessionChangeEvent } from './App';
export type {
WebShellApi,
WebShellProps,
BugReportInfo,
SessionChangeEvent,
} from './App';
export type { ComposerToolbarAction } from './components/ChatEditor';
export type { ToastTone } from './components/ToastHost';
export type { WebShellLanguage } from './i18n';

View file

@ -99,7 +99,7 @@ export function WebShellWithProviders(props: WebShellWithProvidersProps) {
/** Alias for consumers who prefer a standalone naming style. */
export const StandaloneWebShell = WebShellWithProviders;
export type { WebShellProps, WebShellSidebarOptions } from './App';
export type { WebShellApi, WebShellProps, WebShellSidebarOptions } from './App';
export type { ToastTone } from './components/ToastHost';
export type { WebShellLanguage } from './i18n';
export type {