mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
1f92787aa0
commit
b2b02d27ff
6 changed files with 604 additions and 60 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue