From b2b02d27ffb1146fe89a000479d3386530164880 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Wed, 8 Jul 2026 20:02:10 +0800 Subject: [PATCH] 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 --- packages/web-shell/client/App.test.tsx | 335 +++++++++++++++++- packages/web-shell/client/App.tsx | 133 ++++++- .../client/components/SplitView.test.tsx | 107 ++++-- .../web-shell/client/components/SplitView.tsx | 80 ++++- packages/web-shell/client/index.ts | 7 +- packages/web-shell/client/index.tsx | 2 +- 6 files changed, 604 insertions(+), 60 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 5bd3197ee2..678d881a8a 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -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 = {}): { container: HTMLElement; rerender: (nextProps?: React.ComponentProps) => void; + unmount: () => void; } { const container = document.createElement('div'); document.body.appendChild(container); @@ -453,8 +455,15 @@ function renderApp(props: React.ComponentProps = {}): { }); }; 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 { @@ -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('[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('[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(); + const { container } = renderApp({ + sidebar: false, + onSplitSessionIdsChange, + shellRef, + }); + await flush(); + + await act(async () => { + shellRef.current?.openSplitView(); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector('[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(); + 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(); + 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('[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. diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 9360815640..1c0df7a9d3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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; /** 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).current = value; } +function assignShellRef( + ref: React.Ref | undefined, + value: WebShellApi | null, +): void { + if (!ref) return; + if (typeof ref === 'function') { + ref(value); + return; + } + (ref as React.MutableRefObject).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( + () => ({ + 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 — 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({ { 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( + + {}} sessionIds={['s1', 's2']} /> + , + ), + ); + expect(titles()).toEqual(['One', 'Two']); + + act(() => + root!.render( + + {}} sessionIds={[]} /> + , + ), + ); + 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( + + {}} + sessionIds={['s1', 's2']} + onPanesChange={onPanesChange} + /> + , + ), + ); + 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', () => { {}} - initialSessionIds={['s1']} + sessionIds={['s1']} sessionListReloadToken={1} /> , @@ -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']); }); }); diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index 6d42e9148f..b0096b3df7 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -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(() => { - 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)), diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts index 3450dffc33..a3c2c4483e 100644 --- a/packages/web-shell/client/index.ts +++ b/packages/web-shell/client/index.ts @@ -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'; diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 6c042f3f6a..1264796f55 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -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 {