diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 2be0df1d06f..5a5c1180d79 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -400,6 +400,93 @@ overflow: visible; } +/* In-place panel that replaces the chat view (message list + composer) when + Settings or Daemon Status is opened. Fills the chat pane and owns its own + scroll container, mirroring what the DialogShell body used to provide. */ +.panelHost { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panelHeader { + display: flex; + align-items: center; + gap: 10px; + min-height: 46px; + padding: 10px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.panelBack { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + flex: 0 0 auto; + padding: 0; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} + +.panelBack:hover, +.panelBack:focus-visible { + background: var(--accent); + color: var(--foreground); +} + +.panelBack svg { + width: 18px; + height: 18px; +} + +.panelTitle { + min-width: 0; + flex: 1 1 auto; + color: var(--foreground); + font-size: 14px; + font-weight: 600; + line-height: 1.35; +} + +.panelBody { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 16px 20px; +} + +/* The chat view (message list + composer) stays mounted while a panel is shown, + just hidden — so the composer keeps its unsent draft and attachments instead + of losing them on unmount. */ +.chatViewWrap { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chatViewHidden { + display: none; +} + +/* Empty (new-chat) state centers the welcome header + composer vertically via + `.appChatEmpty .chatPane { justify-content: center }`. For that to take effect + the wrapper must shrink to its content instead of filling the pane — the same + thing `.content` already does in this state. */ +.appChatEmpty .chatViewWrap { + flex: 0 0 auto; + overflow: visible; +} + .footer { position: relative; width: min(100%, var(--chat-shell-width)); diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 032527e8237..acf6d1be0ac 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -27,6 +27,7 @@ type ChatEditorTestProps = { commitAccepted?: () => void, ) => boolean | void; isPreparing?: boolean; + dialogOpen?: boolean; }; const { @@ -40,6 +41,8 @@ const { rawEnqueuePrompt, editorClear, editorCommit, + editorFocus, + settingsReload, } = vi.hoisted(() => { const connection: MockConnection = { status: 'connected', @@ -70,6 +73,7 @@ const { forkSession: vi.fn().mockResolvedValue({ launched: false }), sendShellCommand: vi.fn().mockResolvedValue(undefined), getStats: vi.fn().mockResolvedValue({}), + loadSession: vi.fn().mockResolvedValue(undefined), }, mockWorkspaceActions: { loadSkillsStatus: vi.fn().mockResolvedValue({ skills: [] }), @@ -100,6 +104,8 @@ const { rawEnqueuePrompt: vi.fn(() => true), editorClear: vi.fn(), editorCommit: vi.fn(), + editorFocus: vi.fn(), + settingsReload: vi.fn().mockResolvedValue(undefined), }; }); @@ -117,7 +123,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useSettings: () => ({ settings: [], setValue: vi.fn().mockResolvedValue(undefined), - reload: vi.fn().mockResolvedValue(undefined), + reload: settingsReload, loading: false, }), useStreamingState: () => testState.streamingState, @@ -165,12 +171,16 @@ vi.mock('./components/ChatEditor', async () => { ref: React.ForwardedRef<{ clear: () => void; insertText: (text: string) => void; + focus: () => void; }>, ) { testState.latestChatEditorProps = props; React.useImperativeHandle(ref, () => ({ clear: editorClear, insertText: vi.fn(), + // The panel focus effect calls editorRef.current?.focus() when a panel + // closes with no pending approval (e.g. resuming a session). + focus: editorFocus, })); return React.createElement( 'button', @@ -214,12 +224,82 @@ vi.mock('./components/MessageList', async () => { }; }); +// SettingsMessage / ModelDialog expose their callbacks as buttons so tests can +// walk the fast-model path: open Settings -> onSubDialog('fastModel') opens the +// model picker -> onSelect fires handleFastModelSelect. +vi.mock('./components/messages/SettingsMessage', async () => { + const React = await import('react'); + return { + SettingsMessage: (props: { onSubDialog?: (key: string) => void }) => + React.createElement( + 'div', + { 'data-testid': 'settings-message' }, + React.createElement( + 'button', + { + 'data-testid': 'open-fast-model', + type: 'button', + onClick: () => props.onSubDialog?.('fastModel'), + }, + 'fast model', + ), + ), + }; +}); + +vi.mock('./components/dialogs/ModelDialog', async () => { + const React = await import('react'); + return { + ModelDialog: (props: { onSelect?: (id: string) => void }) => + React.createElement( + 'button', + { + 'data-testid': 'model-select', + type: 'button', + onClick: () => props.onSelect?.('fast-model-x'), + }, + 'select model', + ), + }; +}); + +// Render DialogShell as an observable container so tests can detect an open +// sub-dialog (model picker, approval-mode picker) via [data-testid="dialog-shell"]. +vi.mock('./components/dialogs/DialogShell', async () => { + const React = await import('react'); + return { + DialogShell: (props: { children?: React.ReactNode }) => + React.createElement( + 'div', + { 'data-testid': 'dialog-shell' }, + props.children, + ), + }; +}); + vi.mock('./components/sidebar/WebShellSidebar', async () => { const React = await import('react'); return { - WebShellSidebar: (props: { sessionListReloadToken?: number }) => { + WebShellSidebar: (props: { + sessionListReloadToken?: number; + onOpenDaemonStatus?: () => void; + }) => { sidebarTokens.push(props.sessionListReloadToken); - return React.createElement('div', { 'data-testid': 'sidebar' }); + // Expose the Daemon Status opener so tests can exercise the + // activePanel === 'status' branch (there is no slash command for it). + return React.createElement( + 'div', + { 'data-testid': 'sidebar' }, + React.createElement( + 'button', + { + 'data-testid': 'open-daemon-status', + type: 'button', + onClick: props.onOpenDaemonStatus, + }, + 'daemon status', + ), + ); }, }; }); @@ -240,10 +320,12 @@ mockComponent('./components/panels/TodoPanel', 'TodoPanel'); mockComponent('./components/WelcomeHeader', 'WelcomeHeader'); mockComponent('./components/dialogs/ApprovalModeDialog', 'ApprovalModeDialog'); mockComponent('./components/dialogs/ResumeDialog', 'ResumeDialog'); -mockComponent('./components/dialogs/DialogShell', 'DialogShell'); -mockComponent('./components/dialogs/ModelDialog', 'ModelDialog'); mockComponent('./components/dialogs/ToolsDialog', 'ToolsDialog'); mockComponent('./components/dialogs/DaemonStatusDialog', 'DaemonStatusDialog'); +mockComponent( + './components/dialogs/ScheduledTasksDialog', + 'ScheduledTasksDialog', +); mockComponent('./components/dialogs/ExtensionsDialog', 'ExtensionsDialog'); mockComponent('./components/dialogs/ThemeDialog', 'ThemeDialog'); mockComponent( @@ -259,7 +341,6 @@ mockComponent('./components/dialogs/McpDialog', 'McpDialog'); mockComponent('./components/messages/AgentsMessage', 'AgentsMessage'); mockComponent('./components/messages/MemoryMessage', 'MemoryMessage'); mockComponent('./components/messages/AuthMessage', 'AuthMessage'); -mockComponent('./components/messages/SettingsMessage', 'SettingsMessage'); mockComponent('./components/messages/ToolApproval', 'ToolApproval'); mockComponent('./components/messages/AskUserQuestion', 'AskUserQuestion'); mockComponent('./components/messages/TasksStatusMessage', 'TasksStatusMessage'); @@ -306,6 +387,38 @@ async function clickSubmit(container: HTMLElement): Promise { }); } +// A transcript block shaped like extractPendingPermission() expects. Defaults to +// a non-AskUserQuestion tool (→ pendingToolApproval); pass toolName +// 'ask_user_question' to exercise the pendingAskUserApproval branch instead. +// isAskUserPermission() classifies by rawInput.questions being a non-empty +// array, so the ask-user variant carries a toolCall.input.questions payload +// (getPermissionRawInput reads toolCall.input) — a bare toolName isn't enough. +function makePendingPermissionBlock( + overrides: { resolved?: boolean; toolName?: string } = {}, +): unknown { + const toolName = overrides.toolName ?? 'run_shell_command'; + const isAskUser = toolName === 'ask_user_question'; + return { + kind: 'permission', + resolved: overrides.resolved ?? false, + requestId: 'req-1', + sessionId: 'session-1', + title: 'Run ls', + toolCall: { + toolCallId: 'tc-1', + kind: isAskUser ? 'other' : 'execute', + _meta: { toolName }, + ...(isAskUser + ? { input: { questions: [{ question: 'Pick one', options: [] }] } } + : {}), + }, + options: [ + { optionId: 'proceed_once', label: 'Allow', raw: {} }, + { optionId: 'cancel', label: 'Reject', raw: {} }, + ], + }; +} + beforeEach(() => { Object.defineProperty(window, 'matchMedia', { configurable: true, @@ -327,6 +440,9 @@ beforeEach(() => { rawEnqueuePrompt.mockClear(); editorClear.mockClear(); editorCommit.mockClear(); + editorFocus.mockClear(); + settingsReload.mockClear(); + settingsReload.mockResolvedValue(undefined); mockFollowup.clear.mockClear(); for (const value of Object.values(mockSessionActions)) { if (typeof value === 'function' && 'mockClear' in value) value.mockClear(); @@ -345,6 +461,7 @@ beforeEach(() => { mockSessionActions.forkSession.mockResolvedValue({ launched: false }); mockSessionActions.sendShellCommand.mockResolvedValue(undefined); mockSessionActions.getStats.mockResolvedValue({}); + mockSessionActions.loadSession.mockResolvedValue(undefined); mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] }); mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null }); mockWorkspaceActions.loadPreflight.mockResolvedValue(null); @@ -575,6 +692,383 @@ describe('App session callbacks', () => { ); }); + it('auto-closes an open Settings/Status panel when a tool approval becomes pending', async () => { + // Regression: the approval overlay lives in the chat footer, which is + // hidden (display:none) while a panel is shown. If a gated tool call + // arrives while Settings/Status is open, the panel must step aside so the + // approval is visible instead of the turn hanging behind it. + const { container, rerender } = renderApp(); + await flush(); + + // Open the Settings panel via the /settings command; the panel host carries + // data-testid="inline-panel", so its presence tracks the panel. + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + + // A gated tool call arrives. + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + }); + + it('auto-closes an open panel when an AskUserQuestion approval becomes pending', async () => { + // The auto-close effect gates on pendingToolApproval || pendingAskUserApproval; + // this covers the second branch (ask_user_question resolves to + // pendingAskUserApproval), whose overlay is also hidden behind the panel. + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + + await act(async () => { + testState.blocks = [ + makePendingPermissionBlock({ toolName: 'ask_user_question' }), + ]; + rerender(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + }); + + it('opens the Daemon Status panel and auto-closes it on a pending approval', async () => { + // Covers the activePanel === 'status' branch (DaemonStatusDialog); the other + // panel tests all open via /settings, so this guards the 'status' literal and + // confirms the auto-close is panel-type-agnostic. + const { container, rerender } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-daemon-status"]') + ?.click(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + 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). + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/schedule'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="scheduled-tasks-page"]'), + ).not.toBeNull(); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="scheduled-tasks-page"]'), + ).toBeNull(); + }); + + it('opening Daemon Status closes the Scheduled Tasks page (mutually exclusive full-pane views)', async () => { + // Regression: both are full-pane views; the Scheduled Tasks fullPage is a + // position:absolute overlay, so opening Daemon Status while it was up left + // the panel rendered *behind* it — the button looked dead. + const { container } = renderApp(); + await flush(); + + testState.prompt = '/schedule'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="scheduled-tasks-page"]'), + ).not.toBeNull(); + + await act(async () => { + container + .querySelector('[data-testid="open-daemon-status"]') + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="scheduled-tasks-page"]'), + ).toBeNull(); + }); + + it('opening Scheduled Tasks closes an open Settings/Status panel', async () => { + const { container } = renderApp(); + await flush(); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + + testState.prompt = '/schedule'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="scheduled-tasks-page"]'), + ).not.toBeNull(); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + }); + + it('keeps the panel open when transcript blocks carry no actionable approval', async () => { + // Negative control: a resolved permission is not actionable, so the panel + // must stay put (guards against an unconditional "close on any block"). + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock({ resolved: true })]; + rerender(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + }); + + it('keeps the composer dormant (dialogOpen) while an approval overlay is up', async () => { + // Regression: after the panel auto-closes for an approval, interactionBlocked + // flips false. Unless dialogOpen also keys off the pending approval, + // useComposerCore refocuses the composer and ToolApproval — which ignores + // keys from editable targets — stops responding to its approval shortcuts. + const { rerender } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.dialogOpen).toBe(false); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + + expect(testState.latestChatEditorProps?.dialogOpen).toBe(true); + }); + + it('dismisses an open sub-dialog (model picker) when an approval becomes pending', async () => { + // A DialogShell sub-dialog left open would sit (backdrop) over the approval + // overlay in the chat footer, hiding it — and, for the approval-mode picker, + // let the user yolo-approve an unseen tool call. /model (no arg) opens the + // picker; an approval must dismiss it. + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/model'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="dialog-shell"]')).not.toBeNull(); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull(); + }); + + it('moves focus to the approval overlay when it appears', async () => { + const { rerender } = renderApp(); + await flush(); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + + const overlay = document.querySelector('[data-testid="approval-overlay"]'); + expect(overlay).not.toBeNull(); + expect(document.activeElement).toBe(overlay); + }); + + it('closes the panel on Escape from outside the sidebar', async () => { + const { container } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + const panel = container.querySelector('[data-testid="inline-panel"]'); + expect(panel).not.toBeNull(); + + await act(async () => { + panel?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + }); + + it('keeps the panel open on Escape originating inside the sidebar', async () => { + const { container } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + + const sidebar = container.querySelector('[data-testid="sidebar"]'); + await act(async () => { + sidebar?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + }); + + it('marks the composer dormant (dialogOpen) while a panel replaces the chat', async () => { + const { container } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.dialogOpen).toBe(false); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(testState.latestChatEditorProps?.dialogOpen).toBe(true); + }); + + it('restores composer focus after an approval resolves following a panel auto-close', async () => { + // Regression: on panel auto-close the editor focus is intentionally skipped + // (the approval owns the keyboard); when the approval later resolves with no + // panel to return to, focus must come back to the composer rather than being + // orphaned on . + const { container, rerender } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + + await act(async () => { + testState.blocks = [makePendingPermissionBlock()]; + rerender(); + await Promise.resolve(); + }); + editorFocus.mockClear(); + + await act(async () => { + testState.blocks = []; + rerender(); + await Promise.resolve(); + }); + expect(editorFocus).toHaveBeenCalled(); + }); + + it('closes the panel and restores composer focus on Back button click', async () => { + const { container } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + editorFocus.mockClear(); + + await act(async () => { + container + .querySelector('[data-testid="panel-back"]') + ?.click(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + expect(editorFocus).toHaveBeenCalled(); + }); + + it('closes the panel, sends /model --fast, and reloads settings on fast-model pick', async () => { + const { container } = renderApp(); + await flush(); + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + + // Open the fast-model picker from Settings, then pick a model. + await act(async () => { + container + .querySelector('[data-testid="open-fast-model"]') + ?.click(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="dialog-shell"]')).not.toBeNull(); + + await act(async () => { + container + .querySelector('[data-testid="model-select"]') + ?.click(); + await Promise.resolve(); + }); + await flush(); + + expect( + mockSessionActions.sendPrompt.mock.calls.some( + (c) => c[0] === '/model --fast fast-model-x', + ), + ).toBe(true); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + expect(settingsReload).toHaveBeenCalled(); + }); + + it('marks the chat view aria-hidden while a panel is shown', async () => { + const { container } = renderApp(); + await flush(); + expect( + container + .querySelector('[data-testid="submit"]') + ?.closest('[aria-hidden="true"]'), + ).toBeNull(); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect( + container + .querySelector('[data-testid="submit"]') + ?.closest('[aria-hidden="true"]'), + ).not.toBeNull(); + }); + + it('closes an open panel when resuming a session via /resume', async () => { + // Resuming a session must surface that chat, not leave it hidden behind an + // open Settings/Status panel — mirrors createNewSession / loadSidebarSession. + const { container } = renderApp(); + await flush(); + + testState.prompt = '/settings'; + await clickSubmit(container); + await flush(); + expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull(); + + testState.prompt = '/resume session-2'; + await clickSubmit(container); + await flush(); + + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-2'); + }); + it('dispatches rename only after the current session name changes', async () => { const onSessionChange = vi.fn(); const { rerender } = renderApp({ onSessionChange }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 351c8aa77d2..356f6f8e570 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -861,7 +861,7 @@ export function App({ // the drawer on the first Escape. if ( isEditableTarget(target) && - !target?.closest('[data-mobile-drawer]') + !target?.closest('[data-sidebar-shell]') ) { return; } @@ -873,12 +873,12 @@ export function App({ document.body.style.overflow = 'hidden'; const preventScroll = (e: TouchEvent) => { // Allow native scrolling inside the drawer panel (e.g. the session list). - // The dim backdrop also lives under [data-mobile-drawer], so exclude it: + // The dim backdrop also lives under [data-sidebar-shell], so exclude it: // a touchmove starting on the backdrop must still be blocked, otherwise // iOS Safari scrolls the page behind the open drawer. const el = e.target as HTMLElement | null; if ( - el?.closest('[data-mobile-drawer]') && + el?.closest('[data-sidebar-shell]') && !el.closest(`.${styles.mobileBackdrop}`) ) { return; @@ -1018,6 +1018,13 @@ export function App({ : null; const pendingApprovalRef = useRef(pendingApproval); pendingApprovalRef.current = canActOnPendingApproval ? pendingApproval : null; + // True exactly when an actionable approval overlay (ToolApproval or + // AskUserQuestion) is on screen. Single source of truth for the three places + // that must treat the composer as dormant while an approval owns the keyboard: + // the panel auto-close, the panel focus-restore guard, and the ChatEditor + // dialogOpen prop. + const approvalOverlayActive = + pendingToolApproval !== null || pendingAskUserApproval !== null; const floatingTodosState = useMemo( () => getFloatingTodos(messages), [messages], @@ -1193,15 +1200,124 @@ export function App({ const [showHelpDialog, setShowHelpDialog] = useState(false); const [showThemeDialog, setShowThemeDialog] = useState(false); const [showToolsDialog, setShowToolsDialog] = useState(false); - const [showDaemonStatusDialog, setShowDaemonStatusDialog] = useState(false); // Main content view. The scheduled-tasks page replaces the chat pane inline // (not a modal overlay), mirroring the reference design; creating or opening - // a chat returns to 'chat'. + // 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 [showExtensionsDialog, setShowExtensionsDialog] = useState(false); const [mcpDialogMessage, setMcpDialogMessage] = useState(null); - const [showSettingsDialog, setShowSettingsDialog] = useState(false); + // 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 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 + // position:absolute overlay that would otherwise cover the former — so opening + // 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 openScheduledTasks = useCallback(() => { + setActivePanel(null); + setMainView('scheduledTasks'); + }, []); + // 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 + // when it closes, so keyboard users aren't stranded on an element that is + // about to be hidden. + const panelBackRef = useRef(null); + const prevActivePanelRef = useRef(activePanel); + const prevApprovalOverlayRef = useRef(approvalOverlayActive); + useEffect(() => { + const prev = prevActivePanelRef.current; + const wasApprovalActive = prevApprovalOverlayRef.current; + prevActivePanelRef.current = activePanel; + prevApprovalOverlayRef.current = approvalOverlayActive; + if (activePanel) { + // Covers null→panel and panel→panel: the Back button lives outside the + // keyed panel body so it survives a switch, but refocus explicitly rather + // than depending on that DOM coincidence. + panelBackRef.current?.focus(); + } else if (prev) { + // Panel just closed. Return focus to the composer — unless an approval + // overlay is what forced it closed (see the effect below): that overlay + // drives its own keyboard handling and ToolApproval ignores keys from + // editable targets, so focusing the composer here would swallow its + // shortcuts and leave the user unable to respond by keyboard. + if (!approvalOverlayActive) { + editorRef.current?.focus(); + } + } else if (wasApprovalActive && !approvalOverlayActive) { + // The panel was auto-closed for an approval (prev was consumed to null on + // that render, editor focus skipped). Now the approval has resolved with + // no panel to return to, so restore the composer here. (useComposerCore's + // dialogOpen effect also refocuses on this transition; this keeps the + // panel focus effect self-contained instead of relying on that.) + editorRef.current?.focus(); + } + }, [activePanel, approvalOverlayActive]); + // A pending approval (a gated tool call or an AskUserQuestion) renders its + // overlay in the chat footer, which is hidden (display:none) while a panel is + // shown. Left alone, the turn would hang behind Settings/Status with no + // visible prompt. Close the panel so the approval surfaces. Only actionable + // approvals count — pendingToolApproval/pendingAskUserApproval already gate on + // canActOnPendingApproval, so a non-owner in a shared session isn't yanked out + // of Settings by someone else's prompt. + useEffect(() => { + if (!approvalOverlayActive) return; + // The approval overlay renders in the chat footer; dismiss anything layered + // over it so it's visible and actionable instead of trapped behind a + // backdrop — the panel itself and any DialogShell sub-dialog opened from it + // (model picker, approval-mode picker). Leaving the approval-mode picker up + // is also a security hole: the user could pick "yolo" and silently + // auto-approve a tool call they never saw (handleSetMode auto-approves + // pendingApprovalRef.current). + if (activePanel) setActivePanel(null); + 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'); + }, [ + approvalOverlayActive, + activePanel, + modelDialogMode, + showApprovalModeDialog, + mainView, + ]); + // Once the effect above uncovers the approval, the overlay is the topmost + // surface but the just-unmounted panel Back button dropped focus to . + // Move focus onto the overlay when it becomes visible so keyboard/AT users + // land on it. Only for ToolApproval: it drives keyboard entirely through a + // window listener, so focusing its (tabindex=-1) wrapper is safe and gives AT + // a landing spot without confirming (Enter arms first, confirms second — a + // focused button would confirm on the first press). AskUserQuestion instead + // manages its own focus across its options/input, so stealing focus to the + // wrapper would break its arrow-key navigation. + const approvalOverlayRef = useRef(null); + const toolApprovalOverlayVisible = + pendingToolApproval !== null && + !activePanel && + modelDialogMode === null && + !showApprovalModeDialog && + mainView === 'chat'; + const prevToolApprovalOverlayVisibleRef = useRef(toolApprovalOverlayVisible); + useEffect(() => { + const wasVisible = prevToolApprovalOverlayVisibleRef.current; + prevToolApprovalOverlayVisibleRef.current = toolApprovalOverlayVisible; + if (toolApprovalOverlayVisible && !wasVisible) { + approvalOverlayRef.current?.focus(); + } + }, [toolApprovalOverlayVisible]); const [showMemoryDialog, setShowMemoryDialog] = useState(false); const [showAuthDialog, setShowAuthDialog] = useState(false); const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0); @@ -1481,19 +1597,23 @@ export function App({ showHelpDialog || showThemeDialog || showToolsDialog || - showDaemonStatusDialog || showExtensionsDialog || modelDialogMode !== null || showApprovalModeDialog || tasksDialogMessage !== null || mcpDialogMessage !== null || agentsDialogMode !== null || - showSettingsDialog || showMemoryDialog || - showAuthDialog; - // Block chat interaction (composer, chat keyboard shortcuts) both when a - // modal is open and while a full-pane view (e.g. Scheduled Tasks) covers the - // chat, so keystrokes/Escape can't reach the hidden composer underneath. + showAuthDialog || + // The Settings / Daemon Status panel replaces the chat surface, so — like a + // modal — it must suppress chat-only global shortcuts (Ctrl+L/O/Y, the + // Shift+Tab mode cycle, the btw hotkey). Escape is intercepted earlier and + // returns to the chat instead of falling through to those handlers. + activePanel !== null; + // Block chat interaction (composer, chat keyboard shortcuts) both when a modal + // is open (dialogOpen, which already includes the Settings/Status panel) and + // while a full-pane view (the Scheduled Tasks page) covers the chat, so + // keystrokes/Escape can't reach the hidden composer underneath. const interactionBlocked = dialogOpen || mainView !== 'chat'; const reportError = useCallback( @@ -2269,6 +2389,9 @@ export function App({ // Close the drawer before awaiting so a failed createSession() doesn't leave // it stuck open with the page scroll still locked, matching loadSidebarSession. closeMobileDrawer(); + // Starting a new chat means the user wants to see it — leave any open + // Settings/Status panel so the fresh chat is visible (no-op when closed). + closePanel(); try { await ( sessionActions as typeof sessionActions & SessionActionsWithCreate @@ -2278,7 +2401,7 @@ export function App({ reportError(error, 'Failed to start a new chat'); return false; } - }, [closeMobileDrawer, reportError, sessionActions]); + }, [closeMobileDrawer, closePanel, reportError, sessionActions]); const loadSidebarSession = useCallback( async (sessionId: string) => { @@ -2286,6 +2409,9 @@ export function App({ // Close the drawer before awaiting the load; the transcript clears // immediately and shows its loading skeleton for the selected session. closeMobileDrawer(); + // Loading another session should reveal its chat, not stay on the + // Settings/Status panel (no-op when the panel is closed). + closePanel(); try { await sessionActions.loadSession(sessionId); } catch (error) { @@ -2295,7 +2421,7 @@ export function App({ throw error; } }, - [closeMobileDrawer, sessionActions], + [closeMobileDrawer, closePanel, sessionActions], ); useEffect(() => { @@ -2944,11 +3070,11 @@ export function App({ return true; } if (cmd === 'settings') { - setShowSettingsDialog(true); + openPanel('settings'); return true; } if (cmd === 'schedule') { - setMainView('scheduledTasks'); + openScheduledTasks(); return true; } if (cmd === 'context') { @@ -3162,6 +3288,10 @@ export function App({ const sessionId = text.slice(match[0].length).trim(); if (sessionId) { closeMobileDrawer(); + // Resuming a session means the user wants to see that chat, so + // close any open Settings/Status panel (no-op when already closed), + // consistent with createNewSession / loadSidebarSession. + closePanel(); sessionActions.loadSession(sessionId).catch((error: unknown) => { reportError(error, 'Failed to load session'); }); @@ -3350,6 +3480,9 @@ export function App({ echoOrDeferLocalCommand, branchCurrentSession, closeMobileDrawer, + closePanel, + openPanel, + openScheduledTasks, createNewSession, handleBusyGoalClear, handleGoalSlashCommand, @@ -3500,6 +3633,8 @@ export function App({ streamingState, pendingApproval, interactionBlocked, + activePanel, + closePanel, handleCancel, handleCycleMode, }); @@ -3507,6 +3642,8 @@ export function App({ streamingState, pendingApproval, interactionBlocked, + activePanel, + closePanel, handleCancel, handleCycleMode, }; @@ -3536,6 +3673,24 @@ export function App({ if (e.defaultPrevented || e.isComposing) return; const live = escLiveRef.current; + // A full-view panel (Settings / Daemon Status) replaces the chat rather + // than overlaying it; Escape returns to the chat. Any modal opened on top + // of the panel is a DialogShell, whose own handler stops Escape from + // reaching this window listener, so this only fires when the panel itself + // is the topmost surface. + if (e.key === 'Escape' && live.activePanel) { + // The sidebar stays usable beside the panel and its search input clears + // on Escape without stopping the event; don't also close the panel when + // Escape is being handled inside the sidebar. Scope the panel close to + // Escape originating outside the sidebar drawer. + const target = e.target as HTMLElement | null; + if (!target?.closest('[data-sidebar-shell]')) { + e.preventDefault(); + live.closePanel(); + } + return; + } + if (e.key !== 'Escape') { if (escArmedActionRef.current !== null) { resetEscapeState(); @@ -3625,11 +3780,43 @@ export function App({ // Model IDs from the picker arrive as bare model IDs (baseModelId), not // ACP format. The model picker strips the (authType) suffix before // calling this handler. - sendPrompt(`/model --fast ${modelId}`).catch((error: unknown) => { - reportError(error, 'Failed to switch fast model'); - }); + // + // Close the panel before sending: unlike the vision/voice pickers (silent + // setWorkspaceSetting), `/model --fast` runs a real turn whose response + // lands in the message list. With the panel open the chat is hidden, so + // that response would pile up behind it and surprise the user on close. + // Closing first returns them to the chat to see it in context (matching + // the pre-panel modal behavior). + closePanel(); + sendPrompt(`/model --fast ${modelId}`) + .then(() => { + // sendPrompt resolves only after the `/model --fast` turn *completes* + // (actions.ts → waitForAcceptedPromptCompletion), so the change is + // already applied here — this reload reads the new value, not a stale + // one. It keeps the workspace-settings state fresh for the next time + // Settings is opened (the command path, unlike setWorkspaceSetting, + // doesn't bump the settingsVersion signal). Guard its own rejection — + // the .catch below only covers sendPrompt — and log it so a failed + // reload (leaving stale settings on next open) leaves a trace. + reloadWorkspaceSettings().catch((err: unknown) => { + console.warn( + '[web-shell] failed to reload workspace settings after fast-model switch', + err, + ); + }); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to switch fast model'); + }); }, - [blockLocalCommandDuringTurn, sendPrompt, streamingState, reportError], + [ + blockLocalCommandDuringTurn, + closePanel, + sendPrompt, + streamingState, + reportError, + reloadWorkspaceSettings, + ], ); const handleVoiceModelSelect = useCallback( @@ -3791,6 +3978,7 @@ export function App({ { closeMobileDrawer(); + closePanel(); sessionActions .loadSession(sessionId) .catch((error: unknown) => { @@ -3852,16 +4040,6 @@ export function App({ )} - {showDaemonStatusDialog && ( - setShowDaemonStatusDialog(false)} - > - - - )} {showExtensionsDialog && ( )} - {showSettingsDialog && ( - setShowSettingsDialog(false)} - > - { - setShowSettingsDialog(false); - if (key === 'fastModel') setModelDialogMode('fast'); - else if (key === 'visionModel') setModelDialogMode('vision'); - else if (key === 'tools.approvalMode') - setShowApprovalModeDialog(true); - }} - /> - - )} {showMemoryDialog && ( {sidebarOptions.enabled && (
{ closeMobileDrawer(); - setShowSettingsDialog(true); + openPanel('settings'); }} onOpenDaemonStatus={() => { closeMobileDrawer(); - setShowDaemonStatusDialog(true); + openPanel('status'); }} onOpenScheduledTasks={() => { closeMobileDrawer(); - setMainView('scheduledTasks'); + openScheduledTasks(); }} onNewSession={() => { setMainView('chat'); @@ -4147,8 +4302,69 @@ export function App({ )} + {activePanel && ( +
+
+ +
+ {activePanel === 'settings' + ? t('settings.title') + : t('daemon.title')} +
+
+
+ {activePanel === 'settings' ? ( + { + if (key === 'fastModel') setModelDialogMode('fast'); + else if (key === 'visionModel') + setModelDialogMode('vision'); + else if (key === 'tools.approvalMode') + setShowApprovalModeDialog(true); + }} + /> + ) : ( + + )} +
+
+ )} {mainView === 'scheduledTasks' && ( -
+
)} - - - -
0 || - pendingApproval - ? styles.contentHasMessages - : undefined, - ] - .filter(Boolean) - .join(' ')} + + + + +
+ {canScrollMessageListToBottom && ( +
- {btwMessage?.role === 'btw' && ( -
- -
- )} -
- - - -
- {canScrollMessageListToBottom && ( -
- -
- )} - {showFloatingTodos && ( -
- -
- )} - {pendingToolApproval && ( -
-
+ )} + {showFloatingTodos && ( +
+ +
+ )} + {pendingToolApproval && ( +
+ +
+ )} + {pendingAskUserApproval && ( +
+ +
+ )} +
+ + {escapeHintVisible && streamingState === 'idle' && ( +
+ {t('editor.escClearHint')} +
+ )} + -
- )} - {pendingAskUserApproval && ( -
-
- )} -
- - {escapeHintVisible && streamingState === 'idle' && ( -
- {t('editor.escClearHint')} + {CustomFooter ? ( + 0 + ? (connection.tokenCount ?? 0) / + (connection.contextWindow ?? 0) + : 0 + } + activeGoal={activeGoal} + tasks={footerTasks} + availableModes={MODES_CYCLE} + availableModels={(connection.models ?? []) + .filter(isVisibleComposerModel) + .map((m) => ({ + id: m.id, + label: getModelDisplayName(m.label || m.id), + contextWindow: m.contextWindow, + }))} + skills={loadedSkills} + onSelectMode={handleSetMode} + onSelectModel={handleModelSelect} + /> + ) : ( + + setShowApprovalModeDialog((v) => !v) + } + onSelectModel={() => + setModelDialogMode((v) => (v ? null : 'main')) + } + onShowContext={() => + showContextUsage('/context', false) + } + onOpenSettings={() => openPanel('settings')} + ref={statusBarRef} + onOpenTasks={() => openTasksPanel()} + onReturnToInput={handleReturnToEditor} + tasks={backgroundTasks} + activeGoal={activeGoal} + hideSettings={hideSettings} + onToggleShortcuts={handleToggleShortcuts} + compact={true} + /> + )} + {isChatEmptyState && welcomeFooter && ( +
+ {welcomeFooter}
)} - -
- {CustomFooter ? ( - 0 - ? (connection.tokenCount ?? 0) / - (connection.contextWindow ?? 0) - : 0 - } - activeGoal={activeGoal} - tasks={footerTasks} - availableModes={MODES_CYCLE} - availableModels={(connection.models ?? []) - .filter(isVisibleComposerModel) - .map((m) => ({ - id: m.id, - label: getModelDisplayName(m.label || m.id), - contextWindow: m.contextWindow, - }))} - skills={loadedSkills} - onSelectMode={handleSetMode} - onSelectModel={handleModelSelect} - /> - ) : ( - setShowApprovalModeDialog((v) => !v)} - onSelectModel={() => - setModelDialogMode((v) => (v ? null : 'main')) - } - onShowContext={() => showContextUsage('/context', false)} - onOpenSettings={() => setShowSettingsDialog(true)} - ref={statusBarRef} - onOpenTasks={() => openTasksPanel()} - onReturnToInput={handleReturnToEditor} - tasks={backgroundTasks} - activeGoal={activeGoal} - hideSettings={hideSettings} - onToggleShortcuts={handleToggleShortcuts} - compact={true} - /> - )} - {isChatEmptyState && welcomeFooter && ( -
- {welcomeFooter} -
- )} -
- + +
diff --git a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.module.css b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.module.css index 67feb142475..a8b8a7aaf24 100644 --- a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.module.css +++ b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.module.css @@ -108,17 +108,20 @@ border-bottom-color: var(--primary); } +/* Overview config cards: wider track so a wide panel shows ~3 roomy columns + instead of 4 cramped ones (still collapses to 2 on a narrow viewport). The + metrics tab overrides this with .chartsGrid below. */ .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 12px; } -/* Fullscreen: fewer, wider chart cards so the extra viewport actually enlarges - the charts (paired with a taller SVG in SvgLineChart) instead of just packing - in more small ones. The DialogShell body carries `data-dialog-fullscreen`. */ -:global([data-dialog-fullscreen]) .grid { - grid-template-columns: repeat(auto-fill, minmax(min(100%, 480px), 1fr)); +/* The metrics tab renders in a full-height, extra-wide panel (not the old 70vh + dialog), so give the 11 time-series charts a wider track than the dense + overview cards — legible ~2–3-column trends instead of a squeezed 4-up. */ +.chartsGrid { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 460px), 1fr)); gap: 16px; } diff --git a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx index 9cd97a5c681..1d4d386df24 100644 --- a/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx +++ b/packages/web-shell/client/components/dialogs/DaemonStatusDialog.tsx @@ -333,7 +333,7 @@ function MetricsCharts({ series }: { series: DaemonMetricsSeriesBucket[] }) { ); return ( -
+
{chart('daemon.charts.concurrency', formatCount, [ { label: t('daemon.charts.activePrompts'), diff --git a/packages/web-shell/client/components/dialogs/SvgLineChart.module.css b/packages/web-shell/client/components/dialogs/SvgLineChart.module.css index ab227aceda1..c85b93d9473 100644 --- a/packages/web-shell/client/components/dialogs/SvgLineChart.module.css +++ b/packages/web-shell/client/components/dialogs/SvgLineChart.module.css @@ -13,18 +13,19 @@ .svg { display: block; width: 100%; - height: 52px; + /* The metrics tab now renders in a full-height panel (not the old 70vh + dialog), so give each plot real vertical detail instead of the old 52px + sliver — the taller curve is where most of the "use the height" win comes + from. (The former fullscreen-only 120px override is gone: the panel layout + can't carry `data-dialog-fullscreen`, and 140px is the baseline now.) + Exposed as `--chart-height` so a more constrained caller — e.g. a sidebar + card — can shrink the plot without forking this rule. */ + height: var(--chart-height, 140px); overflow: visible; cursor: crosshair; touch-action: none; } -/* Taller plot when the dialog is fullscreen, so the enlarged cards show more - vertical detail. The DialogShell body carries `data-dialog-fullscreen`. */ -:global([data-dialog-fullscreen]) .svg { - height: 120px; -} - /* Vertical hover cursor: the time line the tooltip reads from. */ .cursor { stroke: var(--muted-foreground); diff --git a/packages/web-shell/client/styles/standalone.css b/packages/web-shell/client/styles/standalone.css index eaf71f7df28..cc50f76a4f8 100644 --- a/packages/web-shell/client/styles/standalone.css +++ b/packages/web-shell/client/styles/standalone.css @@ -89,8 +89,8 @@ body { */ [data-user-selectable], [data-user-selectable] *, -[role='dialog']:not([data-mobile-drawer]), -[role='dialog']:not([data-mobile-drawer]) *, +[role='dialog']:not([data-sidebar-shell]), +[role='dialog']:not([data-sidebar-shell]) *, .cm-content, .cm-line, input,