diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 27ddf37b603..6f983ef903e 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -87,6 +87,34 @@ padding: 14px 20px; border-bottom: 1px solid var(--border); } +/* Non-blocking banner shown at the top of the split when the outer (main) + session is waiting on an approval that's hidden behind the split view. */ +.splitApprovalNotice { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: var(--warning-bg); + border-bottom: 1px solid var(--warning-border); + color: var(--warning-color); + font-size: 13px; +} +.splitApprovalNotice button { + appearance: none; + margin-left: auto; + border: 1px solid var(--warning-border); + background: transparent; + color: inherit; + cursor: pointer; + padding: 3px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; +} +.splitApprovalNotice button:hover { + background: var(--warning-border); +} .fullPageBack { appearance: none; border: none; diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index fc3c115905c..d02b4b6282d 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -289,10 +289,12 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { WebShellSidebar: (props: { sessionListReloadToken?: number; onOpenDaemonStatus?: () => void; + onOpenSessions?: () => void; + onOpenSplitView?: () => void; }) => { sidebarTokens.push(props.sessionListReloadToken); - // Expose the Daemon Status opener so tests can exercise the - // activePanel === 'status' branch (there is no slash command for it). + // Expose the Daemon Status / Session Overview openers so tests can + // exercise those activePanel branches (neither has a slash command). return React.createElement( 'div', { 'data-testid': 'sidebar' }, @@ -305,6 +307,24 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => { }, 'daemon status', ), + React.createElement( + 'button', + { + 'data-testid': 'open-sessions-overview', + type: 'button', + onClick: props.onOpenSessions, + }, + 'sessions overview', + ), + React.createElement( + 'button', + { + 'data-testid': 'open-split-view', + type: 'button', + onClick: props.onOpenSplitView, + }, + 'split view', + ), ); }, }; @@ -328,6 +348,22 @@ mockComponent('./components/dialogs/ApprovalModeDialog', 'ApprovalModeDialog'); mockComponent('./components/dialogs/ResumeDialog', 'ResumeDialog'); mockComponent('./components/dialogs/ToolsDialog', 'ToolsDialog'); mockComponent('./components/dialogs/DaemonStatusDialog', 'DaemonStatusDialog'); +mockComponent('./components/SessionOverviewPanel', 'SessionOverviewPanel'); +vi.doMock('./components/SplitView', async () => { + const React = await import('react'); + return { + SplitView: (props: { onExit?: () => void }) => + React.createElement( + 'div', + { 'data-testid': 'split-view-mock' }, + React.createElement( + 'button', + { 'data-testid': 'split-back', type: 'button', onClick: props.onExit }, + 'back', + ), + ), + }; +}); mockComponent( './components/dialogs/ScheduledTasksDialog', 'ScheduledTasksDialog', @@ -428,11 +464,15 @@ function makePendingPermissionBlock( beforeEach(() => { Object.defineProperty(window, 'matchMedia', { configurable: true, - value: vi.fn().mockReturnValue({ - matches: false, + // Query-aware: report a large screen (min-width matches) so the Session + // Overview entry point is available, while keeping the mobile (max-width) + // query false as the other tests expect. + value: vi.fn().mockImplementation((query: string) => ({ + matches: typeof query === 'string' && query.includes('min-width'), + media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), - }), + })), }); mockConnection.sessionId = 'session-1'; mockConnection.status = 'connected'; @@ -925,6 +965,245 @@ describe('App session callbacks', () => { expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); }); + it('opens the Session Overview panel from the sidebar', async () => { + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + const panel = container.querySelector('[data-testid="inline-panel"]'); + expect(panel).not.toBeNull(); + // The panelHost aria-label distinguishes which panel is up. + expect(panel?.getAttribute('aria-label')).toBe('Session Overview'); + }); + + it('opens the split view from the sidebar', async () => { + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + // The outer chat subtree is hidden (display:none + aria-hidden) behind the + // split, so keyboard/AT can't reach the outer composer/toolbar. Assert the + // node is present first, so a missing subtree fails rather than passing + // vacuously through the optional chain. + const messages = container.querySelector('[data-testid="messages"]'); + expect(messages).not.toBeNull(); + expect(messages?.closest('[aria-hidden="true"]')).not.toBeNull(); + }); + + it('returns to the Session Overview when leaving the split view', async () => { + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + container + .querySelector('[data-testid="split-back"]') + ?.click(); + await Promise.resolve(); + }); + // Split closed; the Session Overview panel is shown instead of the chat. + expect(container.querySelector('[data-testid="split-view-page"]')).toBeNull(); + const panel = container.querySelector('[data-testid="inline-panel"]'); + expect(panel).not.toBeNull(); + expect(panel?.getAttribute('aria-label')).toBe('Session Overview'); + }); + + it('enters the split view from a ?split= URL and consumes the param', async () => { + window.history.pushState({}, '', '/?split=s1,s2'); + try { + const { container } = renderApp(); + await flush(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + // The one-shot param is stripped so a reload/exit doesn't force it back. + expect(window.location.search).toBe(''); + } finally { + window.history.pushState({}, '', '/'); + } + }); + + it('keeps the split view open when an approval becomes pending (unlike the scheduled-tasks page)', async () => { + // Each split pane owns its own session's approval, so an approval on the + // outer main session must NOT yank the user out of the split. + const { container, rerender } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + // The outer session's approval overlay must NOT render behind the split — + // otherwise its global keyboard shortcuts could confirm an unseen approval. + expect( + container.querySelector('[data-testid="approval-overlay"]'), + ).toBeNull(); + }); + + it('surfaces the outer approval as a split notice and returns to chat when clicked', async () => { + // The overlay is suppressed under the split, so the outer approval would be + // invisible; a notice banner (with a way back) is the only signal. + const { container, rerender } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + const notice = container.querySelector( + '[data-testid="split-approval-notice"]', + ); + expect(notice).not.toBeNull(); + // Its button leaves the split (mainView -> 'chat') so the approval overlay, + // which only renders in chat, becomes visible and actionable. + await act(async () => { + notice! + .querySelector('button') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="approval-overlay"]'), + ).not.toBeNull(); + }); + + it('auto-closes the split view when the screen shrinks below the breakpoint', 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 { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + // Shrinking below the large-screen breakpoint folds the split back to chat. + 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. + 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 { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector( + '[data-testid="open-sessions-overview"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + }); + it('dismisses the Scheduled Tasks page when an approval becomes pending', async () => { // The scheduled-tasks fullPage overlay covers the chat footer where the // approval renders, so an approval must close it too (like the panel). diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index bec1766c26d..48fb51a18d4 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -65,10 +65,14 @@ import { MemoryMessage } from './components/messages/MemoryMessage'; import { AuthMessage } from './components/messages/AuthMessage'; import { ToolsDialog } from './components/dialogs/ToolsDialog'; import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog'; +import { SessionOverviewPanel } from './components/SessionOverviewPanel'; +import { SplitView } from './components/SplitView'; +import { useIsLargeScreen } from './hooks/useIsLargeScreen'; +import { parseSplitSessionIds } from './utils/splitUrl'; import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog'; import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog'; import { SettingsMessage } from './components/messages/SettingsMessage'; -import { isAskUserQuestionToolName } from './components/messages/toolFormatting'; +import { isAskUserPermission } from './utils/askUserPermission'; import { ToolApproval } from './components/messages/ToolApproval'; import { AskUserQuestion } from './components/messages/AskUserQuestion'; import { HelpDialog } from './components/dialogs/HelpDialog'; @@ -648,16 +652,6 @@ function isEditToolPermission(request: PermissionRequest): boolean { return request.toolKind === 'edit'; } -function isAskUserPermission(request: PermissionRequest | null): boolean { - if ( - !request?.rawInput?.questions || - !Array.isArray(request.rawInput.questions) - ) { - return false; - } - if (!request.toolName) return true; - return isAskUserQuestionToolName(request.toolName); -} function parseRenameArgument( raw: string, @@ -837,6 +831,10 @@ export function App({ >(null); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const closeMobileDrawer = useCallback(() => setMobileDrawerOpen(false), []); + // The Session Overview panel (mission control for managing many sessions at + // once) is only offered on large screens; below that there is no room for it + // to be useful. + const isLargeScreen = useIsLargeScreen(); useEffect(() => { const mql = window.matchMedia('(max-width: 760px)'); @@ -1207,16 +1205,20 @@ export function App({ // (not a modal overlay), mirroring the reference design; creating or opening // a chat returns to 'chat'. (Daemon Status is no longer a boolean dialog — it // is one of the activePanel values below.) - const [mainView, setMainView] = useState<'chat' | 'scheduledTasks'>('chat'); + const [mainView, setMainView] = useState< + 'chat' | 'scheduledTasks' | 'split' + >('chat'); + // Sessions to seed the split view with (e.g. the selection from the overview). + const [splitSessionIds, setSplitSessionIds] = useState([]); const [showExtensionsDialog, setShowExtensionsDialog] = useState(false); const [mcpDialogMessage, setMcpDialogMessage] = useState(null); // Settings and Daemon Status are shown as an in-place panel that replaces the // chat view (message list + composer), not as a modal overlay. Only one may be // active at a time; null means the normal chat view is shown. - const [activePanel, setActivePanel] = useState<'settings' | 'status' | null>( - null, - ); + const [activePanel, setActivePanel] = useState< + 'settings' | 'status' | 'sessions' | null + >(null); const closePanel = useCallback(() => setActivePanel(null), []); // The Settings/Status panel (activePanel) and the Scheduled Tasks page // (mainView) are mutually-exclusive full-pane views — the latter is a @@ -1224,14 +1226,67 @@ export function App({ // one closes the other. Without this, opening Scheduled Tasks then Daemon // Status left the panel rendered behind the Scheduled Tasks overlay, looking // like the button did nothing. - const openPanel = useCallback((panel: 'settings' | 'status') => { - setMainView('chat'); - setActivePanel(panel); - }, []); + const openPanel = useCallback( + (panel: 'settings' | 'status' | 'sessions') => { + setMainView('chat'); + setActivePanel(panel); + }, + [], + ); const openScheduledTasks = useCallback(() => { setActivePanel(null); setMainView('scheduledTasks'); }, []); + // Open the in-window split view showing 2+ sessions side by side. Seeds with + // the given sessions (e.g. the overview selection); SplitView falls back to + // the current session when the list is empty. + const openSplitView = useCallback((sessionIds?: string[]) => { + setActivePanel(null); + setSplitSessionIds(sessionIds ?? []); + setMainView('split'); + }, []); + // 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], + ); + // 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 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 + // strand the user in a view they can no longer re-enter. + // When a shrink closes the split, its panes unmount and take keyboard focus + // with them; flag the composer to be refocused once the chat is shown again. + const focusComposerAfterSplitCloseRef = useRef(false); + useEffect(() => { + if (!isLargeScreen && activePanel === 'sessions') { + setActivePanel(null); + } + if (!isLargeScreen && mainView === 'split') { + setMainView('chat'); + focusComposerAfterSplitCloseRef.current = true; + } + }, [isLargeScreen, activePanel, mainView]); + // 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). + useEffect(() => { + if (mainView !== 'chat' || !focusComposerAfterSplitCloseRef.current) return; + focusComposerAfterSplitCloseRef.current = false; + if (!activePanel && !approvalOverlayActive) editorRef.current?.focus(); + }, [mainView, activePanel, approvalOverlayActive]); // The Settings / Daemon Status panel is a view, not a modal, so it lacks // DialogShell's focus trap/restore. Move focus to the Back button when a panel // opens (or when switching directly between panels) and back to the composer @@ -1288,8 +1343,11 @@ export function App({ if (modelDialogMode) setModelDialogMode(null); if (showApprovalModeDialog) setShowApprovalModeDialog(false); // The Scheduled Tasks page is a full-pane overlay (position:absolute) that - // covers the chat footer too, so dismiss it for the same reason. - if (mainView !== 'chat') setMainView('chat'); + // covers the chat footer too, so dismiss it for the same reason. The split + // view is deliberately NOT dismissed: each pane owns and renders its own + // session's approval, so an approval on the (outer) main session must not + // yank the user out of the panes they are working in. + if (mainView === 'scheduledTasks') setMainView('chat'); }, [ approvalOverlayActive, activePanel, @@ -2452,6 +2510,19 @@ export function App({ [closeMobileDrawer, closePanel, sessionActions], ); + // Clicking a card in the Session Overview panel switches the current window + // to that session. loadSidebarSession already closes the panel, so this just + // returns to the chat view and reports load failures. + const handleOpenSessionFromOverview = useCallback( + (sessionId: string) => { + setMainView('chat'); + void loadSidebarSession(sessionId).catch((error: unknown) => { + reportError(error, 'Failed to open session'); + }); + }, + [loadSidebarSession, reportError], + ); + useEffect(() => { if ( sidebarSwitchingSessionId !== null && @@ -4292,6 +4363,16 @@ export function App({ closeMobileDrawer(); openScheduledTasks(); }} + onOpenSessions={() => { + closeMobileDrawer(); + openPanel('sessions'); + }} + canOpenSessionsOverview={isLargeScreen} + onOpenSplitView={() => { + closeMobileDrawer(); + openSplitView(); + }} + canOpenSplitView={isLargeScreen} onNewSession={() => { setMainView('chat'); return createNewSession(); @@ -4308,7 +4389,7 @@ export function App({ )}
@@ -4371,7 +4454,9 @@ export function App({
{activePanel === 'settings' ? t('settings.title') - : t('daemon.title')} + : activePanel === 'status' + ? t('daemon.title') + : t('sessionsOverview.title')}
@@ -4391,8 +4476,13 @@ export function App({ setShowApprovalModeDialog(true); }} /> - ) : ( + ) : activePanel === 'status' ? ( + ) : ( + )}
@@ -4458,17 +4548,56 @@ export function App({
)} + {mainView === 'split' && ( +
+ {/* The outer session's approval overlay is suppressed under the + split (it would own ghost keyboard shortcuts). If that + session isn't one of the panes, the approval would be + invisible — surface a notice with a way back to it. */} + {approvalOverlayActive && ( +
+ {t('splitView.outerApprovalPending')} + +
+ )} + {/* Share the app-level customization + compact-mode contexts so + split panes render markdown/tool-headers/thinking the same + way the single-session chat does (todo contexts stay chat- + only — they belong to the outer session, not the panes). */} + + + + + +
+ )}