feat(web-shell): show Settings and Daemon Status as an in-place panel (#6341)

* feat(web-shell): show Settings and Daemon Status as an in-place panel

The Settings and Daemon Status buttons opened centered modal overlays that
dimmed the whole app. Render them as a full-height panel that replaces the
chat surface instead — a Back button or Escape returns to the chat — with the
content left-aligned and filling the chat pane width rather than centered in a
narrow column. On the Daemon Status page, give the time-series charts a taller
plot and the overview cards a wider track so a wide window is actually used;
the tab grouping (overview / metrics / diagnostics) is kept.

* fix(web-shell): preserve composer draft and refine panel focus/escape

Address review feedback on the in-place Settings / Daemon Status panel:

- Keep the chat view (message list + composer) mounted and just hidden while a
  panel is shown, so typing a prompt, opening Settings/Status, then going Back
  no longer discards the unsent draft and attachments (the composer subtree was
  being unmounted and remounted empty).
- Focus the Back button when a panel opens and restore focus to the composer
  when it closes, replacing the focus management DialogShell used to provide.
- Reload workspace settings after the fast-model command resolves so the still-
  mounted Settings panel doesn't keep showing the previous value.
- Don't close the panel when Escape is handled inside the sidebar (its search
  input clears on Escape without stopping the event).
- Guard the overview card grid with min(100%, 340px) so a 340px track can't
  overflow a narrow panel.

* fix(web-shell): reset panel scroll when switching Settings/Status

Add key={activePanel} on the panel body so switching directly between Settings
and Daemon Status (activePanel goes 'settings' -> 'status' without passing
through null) remounts the scroll container and opens at the top instead of
inheriting the previous panel's scrollTop.

* fix(web-shell): restore new-chat vertical centering

The chatViewWrap added to keep the composer mounted while a panel is shown was
filling the pane, which cancelled the empty (new-chat) state's vertical
centering (welcome header + composer stuck to the top instead of centered).
Shrink the wrapper to its content in the empty state so
`.appChatEmpty .chatPane { justify-content: center }` centers it again, matching
how `.content` behaves there.

* fix(web-shell): restore session-org test destructuring dropped in merge

My earlier merge of origin/main 3-way-combined WebShellSidebar.test.tsx
incorrectly, dropping the `{ }` destructuring from the 7 session-organization
tests. renderSidebar returns `{ container, rerender }` (not the element), so
`const container = renderSidebar(...)` made `container.querySelector` throw.
Restore the file to origin/main so the tests pass again.

* fix(web-shell): surface pending approvals over Settings/Status panel

The in-place Settings/Daemon Status panel hides the chat footer with
display:none, and the ToolApproval / AskUserQuestion overlays live in
that footer. A gated tool call that arrived while a panel was open
rendered the approval into a hidden container, so the turn hung with no
visible prompt (reported [Critical]).

Close the panel when an actionable approval is pending so it 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. Leave
focus for the overlay rather than the composer on this path: ToolApproval
uses a window-level key handler that ignores editable targets, so
focusing the composer would swallow its shortcuts.

Also refocus the Back button on panel->panel switches (not just on open),
so focus no longer relies on the Back button being the same DOM node
across the keyed panel body, and expose SvgLineChart's plot height as
--chart-height (fallback 140px) so a constrained caller can shrink it.

Adds App-level tests: the panel auto-closes on a pending tool approval,
and stays open when the only block is a resolved (non-actionable) one.

* test(web-shell): cover AskUserQuestion auto-close; robust panel selector

Follow-up to the approval auto-close fix, addressing review suggestions:

- Add data-testid="inline-panel" to the panel <section> and query by it in
  App.test instead of querySelector('section'), which would false-positive if
  any future component renders a <section>.
- Add a companion test for the pendingAskUserApproval branch of the auto-close
  effect. The ask-user block carries toolCall.input.questions so
  isAskUserPermission() classifies it as an AskUserQuestion; mutation-checked
  (restricting the effect to pendingToolApproval fails only this test).
- Document why handleFastModelSelect's post-sendPrompt reload is not racy:
  sendPrompt awaits waitForAcceptedPromptCompletion, so the /model --fast turn
  has already applied when reloadWorkspaceSettings() runs. Also note why the
  command path needs the explicit reload that the setWorkspaceSetting pickers
  (vision/voice) get for free via the settingsVersion signal.

* fix(web-shell): close inline panel when resuming a session

The /resume <id> command and the ResumeDialog onSelect both call
loadSession() without closePanel(), unlike createNewSession and
loadSidebarSession. Loading a session means the user wants that chat, so
leaving a Settings/Daemon Status panel open would hide it. Add closePanel()
to both paths for consistency (a no-op when no panel is open).

Currently reachable only in theory — the composer that submits /resume is
display:none while a panel is shown — but the guard keeps the invariant if
a non-composer entry point (sidebar/shortcut) is ever added.

Adds a mutation-checked test (removing closePanel from the /resume handler
fails it) and gives the ChatEditor test mock a focus() method, since the
panel-close focus effect now runs editorRef.focus() on this no-approval path.

* fix(web-shell): keep composer dormant while an approval overlay is up

Follow-up to the approval auto-close fix. When an approval arrives while a
Settings/Status panel is open, the auto-close clears activePanel, so
interactionBlocked flips false and useComposerCore's dialogOpen effect
refocuses the still-mounted composer. ToolApproval ignores approval
shortcuts from editable targets, so the now-visible approval stops
responding to Enter/Escape/number keys until focus moves away.

Key the ChatEditor dialogOpen prop off the pending approval too, so the
composer stays blurred while an approval owns the keyboard. Consolidate the
"an approval overlay is active" condition — previously duplicated in the
auto-close effect and the panel focus guard — into a single
approvalOverlayActive value so the three consumers can't drift.

Uses the actionable-approval condition (pendingToolApproval /
pendingAskUserApproval) rather than raw pendingApproval, matching the
auto-close gating and avoiding a blurred composer when a non-actionable
approval renders no overlay.

Adds a mutation-checked test asserting dialogOpen is true while an approval
is pending with no panel open.

* test(web-shell): cover the Daemon Status panel branch

A review note observed that all five panel tests open via /settings, so the
activePanel === 'status' branch (DaemonStatusDialog) had no coverage — a
regression in the 'status' literal would go undetected. Add a test that
opens Daemon Status through the sidebar and asserts the panel opens and
auto-closes on a pending approval, exercising that branch and confirming the
auto-close is panel-type-agnostic.

Gives the WebShellSidebar test mock an onOpenDaemonStatus button, since
there is no slash command to open the status panel. Mutation-checked:
neutering setActivePanel('status') fails only this test.

* fix(web-shell): dismiss stacked dialogs on approval, focus overlay, a11y

Addresses a review round on the inline Settings/Status panel.

[Critical] When an approval arrives while a DialogShell sub-dialog (model
picker / approval-mode picker) is open over the panel, the auto-close
removed the panel but left the sub-dialog backdrop covering the footer
approval overlay, so the turn hung. Worse, the approval-mode picker stayed
usable — selecting "yolo" auto-approved (handleSetMode) a tool call the
user never saw. Dismiss the panel AND both sub-dialogs when an actionable
approval is pending.

[Critical] After the panel closes for an approval, focus fell to <body>
(the Back button unmounted; ToolApproval has no autofocus). Move focus onto
the ToolApproval overlay wrapper (tabindex=-1, so its window-listener
shortcuts keep working and Enter doesn't confirm early) once it is visible.
AskUserQuestion keeps managing its own focus.

Suggestions:
- Guard reloadWorkspaceSettings() rejection (was unhandled).
- aria-hidden the display:none chat view so AT can't wander into it.
- Close the panel before /model --fast so its response shows in the chat
  in context instead of piling up behind the hidden panel.
- Remove the dead .panelBodyInner wrapper (redundant width:100%).
- Rename data-mobile-drawer -> data-sidebar-shell (it wraps the desktop
  sidebar for all viewports) across App.tsx + standalone.css.

Tests (+5, mutation-checked): sub-dialog dismissal on approval, overlay
focus, Escape closes panel / sidebar-Escape does not, dialogOpen while a
panel replaces the chat. Adds an observable DialogShell test mock.

* fix(web-shell): restore composer focus after approval resolves; cleanups

[Critical] The panel focus effect consumed prevActivePanelRef to null on the
approval auto-close (correctly skipping editor focus then). When the approval
later resolved with no panel to return to, neither branch fired and focus was
left on <body> — the visible composer took no keyboard input. Track the
approvalOverlayActive transition too and restore composer focus on
approval-resolve-after-panel-close. (useComposerCore's dialogOpen effect also
covers this; the extra branch makes the panel effect self-contained.)

Suggestions:
- Log the reloadWorkspaceSettings() rejection instead of swallowing it.
- Remove the dead :global([data-dialog-fullscreen]) .grid rule (the
  allowFullscreen source was removed when Daemon Status became a panel).

Tests (+4, focus-restore mutation-checked): focus restored after an approval
resolves post-auto-close; Back-button closes the panel and restores focus;
fast-model pick closes the panel, sends /model --fast, and reloads settings;
chat view is aria-hidden while a panel is shown. Adds interactive
SettingsMessage / ModelDialog mocks and stable editor-focus / settings-reload
spies.

* fix(web-shell): make Settings/Status panel and Scheduled Tasks mutually exclusive

Opening Scheduled Tasks (mainView — a position:absolute z-index overlay) and
then Daemon Status (activePanel) rendered the Daemon Status panel *behind* the
Scheduled Tasks overlay, so the button looked like it did nothing. These are
mutually-exclusive full-pane views, so opening one must close the other.

Centralize into openPanel() / openScheduledTasks() helpers (last-opened wins)
and route all six open sites (the /settings and /schedule commands, the three
sidebar handlers, and the StatusBar) through them.

Mutation-checked tests: opening Daemon Status closes the Scheduled Tasks page
(the reported repro), and opening Scheduled Tasks closes an open panel.
This commit is contained in:
Shaojin Wen 2026-07-06 13:06:46 +08:00 committed by GitHub
parent 1783ae86f3
commit 170ce7917d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1123 additions and 293 deletions

View file

@ -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));

View file

@ -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<void> {
});
}
// 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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 <body>.
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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-testid="open-fast-model"]')
?.click();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="dialog-shell"]')).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[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 });

View file

@ -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<SerializedMcpStatusMessage | null>(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<HTMLButtonElement | null>(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 <body>.
// 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<HTMLDivElement | null>(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({
<ResumeDialog
onSelect={(sessionId) => {
closeMobileDrawer();
closePanel();
sessionActions
.loadSession(sessionId)
.catch((error: unknown) => {
@ -3852,16 +4040,6 @@ export function App({
<ToolsDialog />
</DialogShell>
)}
{showDaemonStatusDialog && (
<DialogShell
title={t('daemon.title')}
size="xl"
allowFullscreen
onClose={() => setShowDaemonStatusDialog(false)}
>
<DaemonStatusDialog />
</DialogShell>
)}
{showExtensionsDialog && (
<DialogShell
title={t('extensions.manage.title')}
@ -3917,29 +4095,6 @@ export function App({
/>
</DialogShell>
)}
{showSettingsDialog && (
<DialogShell
title={t('settings.title')}
size="lg"
onClose={() => setShowSettingsDialog(false)}
>
<SettingsMessage
settingsState={workspaceSettingsState}
embedded
onLanguageChange={handleSettingsLanguageChange}
onThemeChange={handleThemeChange}
chatWidthMode={chatWidthMode}
onChatWidthModeChange={handleChatWidthModeChange}
onSubDialog={(key) => {
setShowSettingsDialog(false);
if (key === 'fastModel') setModelDialogMode('fast');
else if (key === 'visionModel') setModelDialogMode('vision');
else if (key === 'tools.approvalMode')
setShowApprovalModeDialog(true);
}}
/>
</DialogShell>
)}
{showMemoryDialog && (
<DialogShell
title={t('memory.menu')}
@ -4071,7 +4226,7 @@ export function App({
<div className={styles.appShell}>
{sidebarOptions.enabled && (
<div
data-mobile-drawer=""
data-sidebar-shell=""
{...(mobileDrawerOpen
? { role: 'dialog', 'aria-modal': 'true' as const }
: {})}
@ -4093,15 +4248,15 @@ export function App({
onCollapsedChange={handleSidebarCollapsedChange}
onOpenSettings={() => {
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({
</svg>
</button>
)}
{activePanel && (
<section
className={styles.panelHost}
role="region"
data-testid="inline-panel"
aria-label={
activePanel === 'settings'
? t('settings.title')
: t('daemon.title')
}
>
<div className={styles.panelHeader}>
<button
ref={panelBackRef}
type="button"
className={styles.panelBack}
data-testid="panel-back"
onClick={closePanel}
aria-label={t('common.back')}
title={t('common.back')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M15 5l-7 7 7 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<div className={styles.panelTitle}>
{activePanel === 'settings'
? t('settings.title')
: t('daemon.title')}
</div>
</div>
<div className={styles.panelBody} key={activePanel}>
{activePanel === 'settings' ? (
<SettingsMessage
settingsState={workspaceSettingsState}
embedded
onLanguageChange={handleSettingsLanguageChange}
onThemeChange={handleThemeChange}
chatWidthMode={chatWidthMode}
onChatWidthModeChange={handleChatWidthModeChange}
onSubDialog={(key) => {
if (key === 'fastModel') setModelDialogMode('fast');
else if (key === 'visionModel')
setModelDialogMode('vision');
else if (key === 'tools.approvalMode')
setShowApprovalModeDialog(true);
}}
/>
) : (
<DaemonStatusDialog />
)}
</div>
</section>
)}
{mainView === 'scheduledTasks' && (
<div className={styles.fullPage}>
<div className={styles.fullPage} data-testid="scheduled-tasks-page">
<div className={styles.fullPageHeader}>
<button
type="button"
@ -4205,226 +4421,255 @@ export function App({
</div>
</div>
)}
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<TodoContextsProvider
timeline={todoTimeline}
details={todoDetails}
>
<div
className={[
styles.content,
showFloatingTodos ||
displayMessages.length > 0 ||
pendingApproval
? styles.contentHasMessages
: undefined,
]
.filter(Boolean)
.join(' ')}
<div
className={
activePanel
? `${styles.chatViewWrap} ${styles.chatViewHidden}`
: styles.chatViewWrap
}
// `display:none` already drops this subtree from the a11y tree in
// Chromium, but that's a browser detail, not an ARIA guarantee;
// mark it hidden explicitly so AT can't wander into the stale
// chat / hidden composer while a panel is shown.
aria-hidden={activePanel ? true : undefined}
>
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<TodoContextsProvider
timeline={todoTimeline}
details={todoDetails}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingToolApproval}
onShowContextDetail={handleShowContextDetail}
loadingTranscript={connection.loadingTranscript}
catchingUp={connection.catchingUp}
isResponding={streamingState !== 'idle'}
activeTurnStartedAt={activeTurnStartedAt}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
hideSessionTimeline={effectiveChatWidthMode === 'wide'}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
onBranchSession={handleBranchCurrentSession}
welcomeHeader={
isChatEmptyState ? welcomeHeader : undefined
}
tailContent={undefined}
tailKey={undefined}
onCanScrollToBottomChange={
handleCanScrollToBottomChange
}
virtualScrollThreshold={virtualScrollThreshold}
/>
{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}
</div>
</TodoContextsProvider>
</CompactModeContext.Provider>
<div ref={footerRef} className={styles.footer}>
{canScrollMessageListToBottom && (
<div
className={
showFloatingTodos
? `${styles.scrollToBottomLayer} ${styles.scrollToBottomLayerWithTodos}`
: styles.scrollToBottomLayer
}
>
<button
type="button"
className={styles.scrollToBottomButton}
aria-label={t('chat.scrollToBottom')}
onClick={() => resumeChatBottomFollow('smooth')}
<div
className={[
styles.content,
showFloatingTodos ||
displayMessages.length > 0 ||
pendingApproval
? styles.contentHasMessages
: undefined,
]
.filter(Boolean)
.join(' ')}
>
<svg
className={styles.scrollToBottomIcon}
viewBox="0 0 24 24"
aria-hidden="true"
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingToolApproval}
onShowContextDetail={handleShowContextDetail}
loadingTranscript={connection.loadingTranscript}
catchingUp={connection.catchingUp}
isResponding={streamingState !== 'idle'}
activeTurnStartedAt={activeTurnStartedAt}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
hideSessionTimeline={
effectiveChatWidthMode === 'wide'
}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
onBranchSession={handleBranchCurrentSession}
welcomeHeader={
isChatEmptyState ? welcomeHeader : undefined
}
tailContent={undefined}
tailKey={undefined}
onCanScrollToBottomChange={
handleCanScrollToBottomChange
}
virtualScrollThreshold={virtualScrollThreshold}
/>
{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}
</div>
</TodoContextsProvider>
</CompactModeContext.Provider>
<div ref={footerRef} className={styles.footer}>
{canScrollMessageListToBottom && (
<div
className={
showFloatingTodos
? `${styles.scrollToBottomLayer} ${styles.scrollToBottomLayerWithTodos}`
: styles.scrollToBottomLayer
}
>
<button
type="button"
className={styles.scrollToBottomButton}
aria-label={t('chat.scrollToBottom')}
onClick={() => resumeChatBottomFollow('smooth')}
>
<path
d="M12 5v13M6.5 12.5 12 18l5.5-5.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
)}
{showFloatingTodos && (
<div className={styles.bottomPanels}>
<TodoPanel todos={floatingTodos} />
</div>
)}
{pendingToolApproval && (
<div className={styles.approvalOverlay}>
<ToolApproval
request={pendingToolApproval}
onConfirm={handleConfirm}
variant="floating"
/>
</div>
)}
{pendingAskUserApproval && (
<div className={styles.approvalOverlay}>
<AskUserQuestion
request={pendingAskUserApproval}
onConfirm={handleConfirm}
variant="floating"
/>
</div>
)}
<div className={styles.composer}>
<StreamingStatus startedAt={activeTurnStartedAt} />
{escapeHintVisible && streamingState === 'idle' && (
<div className={styles.escClearStatus} role="status">
{t('editor.escClearHint')}
<svg
className={styles.scrollToBottomIcon}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 5v13M6.5 12.5 12 18l5.5-5.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
)}
<QueuedPromptDisplay
prompts={queuedPrompts}
t={t}
onDelete={removeQueuedPrompt}
onInsert={insertQueuedPrompt}
onEdit={editQueuedPrompt}
/>
<ChatEditor
ref={setEditorHandle}
onSubmit={handleEditorSubmit}
onCycleMode={handleCycleMode}
onToggleShortcuts={handleToggleShortcuts}
onCancel={handleCancel}
isRunning={streamingState !== 'idle'}
isPreparing={isPreparingPrompt}
cancelArmed={cancelArmed}
disabled={isDisabled}
commands={commands}
skills={loadedSkills}
slashCommandCategoryOrder={slashCommandCategoryOrder}
atProviders={atProviders}
composerTagIcons={composerTagIcons}
queuedMessages={queuedTexts}
onFocusFooter={handleFocusTaskPill}
onPopQueuedMessages={editLastQueuedPrompt}
onClearQueuedMessages={clearQueuedPrompts}
currentMode={currentMode}
currentModel={currentModel}
chatWidthMode={chatWidthMode}
showChatWidthToggle={!isChatEmptyState}
chatWidthToggleMin={chatWidthToggleMin}
visibleToolbarActions={composerToolbarActions}
availableModels={availableModels}
onSelectMode={handleSetMode}
onSelectModel={handleModelSelect}
onChatWidthModeChange={handleChatWidthModeChange}
sessionName={sessionDisplayName}
dialogOpen={interactionBlocked}
followupState={followupState}
onAcceptFollowup={onAcceptFollowup}
onDismissFollowup={onDismissFollowup}
composerInput={composerInput}
composerInputVersion={composerInputVersion}
placeholderText={t(
getComposerPlaceholderKey({
catchingUp: Boolean(connection.catchingUp),
isPreparingPrompt,
isStreaming: streamingState !== 'idle',
}),
{showFloatingTodos && (
<div className={styles.bottomPanels}>
<TodoPanel todos={floatingTodos} />
</div>
)}
{pendingToolApproval && (
<div
ref={approvalOverlayRef}
tabIndex={-1}
data-testid="approval-overlay"
className={styles.approvalOverlay}
>
<ToolApproval
request={pendingToolApproval}
onConfirm={handleConfirm}
variant="floating"
/>
</div>
)}
{pendingAskUserApproval && (
<div
ref={approvalOverlayRef}
tabIndex={-1}
data-testid="approval-overlay"
className={styles.approvalOverlay}
>
<AskUserQuestion
request={pendingAskUserApproval}
onConfirm={handleConfirm}
variant="floating"
/>
</div>
)}
<div className={styles.composer}>
<StreamingStatus startedAt={activeTurnStartedAt} />
{escapeHintVisible && streamingState === 'idle' && (
<div className={styles.escClearStatus} role="status">
{t('editor.escClearHint')}
</div>
)}
/>
</div>
{CustomFooter ? (
<CustomFooter
connected={connected}
mode={currentMode}
model={currentModel}
streamingState={streamingState}
contextUsageRatio={
(connection.contextWindow ?? 0) > 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}
/>
) : (
<StatusBar
onSelectMode={() => 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 && (
<div className={styles.emptyWelcomeFooter}>
{welcomeFooter}
<QueuedPromptDisplay
prompts={queuedPrompts}
t={t}
onDelete={removeQueuedPrompt}
onInsert={insertQueuedPrompt}
onEdit={editQueuedPrompt}
/>
<ChatEditor
ref={setEditorHandle}
onSubmit={handleEditorSubmit}
onCycleMode={handleCycleMode}
onToggleShortcuts={handleToggleShortcuts}
onCancel={handleCancel}
isRunning={streamingState !== 'idle'}
isPreparing={isPreparingPrompt}
cancelArmed={cancelArmed}
disabled={isDisabled}
commands={commands}
skills={loadedSkills}
slashCommandCategoryOrder={slashCommandCategoryOrder}
atProviders={atProviders}
composerTagIcons={composerTagIcons}
queuedMessages={queuedTexts}
onFocusFooter={handleFocusTaskPill}
onPopQueuedMessages={editLastQueuedPrompt}
onClearQueuedMessages={clearQueuedPrompts}
currentMode={currentMode}
currentModel={currentModel}
chatWidthMode={chatWidthMode}
showChatWidthToggle={!isChatEmptyState}
chatWidthToggleMin={chatWidthToggleMin}
visibleToolbarActions={composerToolbarActions}
availableModels={availableModels}
onSelectMode={handleSetMode}
onSelectModel={handleModelSelect}
onChatWidthModeChange={handleChatWidthModeChange}
sessionName={sessionDisplayName}
dialogOpen={interactionBlocked || approvalOverlayActive}
followupState={followupState}
onAcceptFollowup={onAcceptFollowup}
onDismissFollowup={onDismissFollowup}
composerInput={composerInput}
composerInputVersion={composerInputVersion}
placeholderText={t(
getComposerPlaceholderKey({
catchingUp: Boolean(connection.catchingUp),
isPreparingPrompt,
isStreaming: streamingState !== 'idle',
}),
)}
/>
</div>
)}
</div>
</WebShellCustomizationProvider>
{CustomFooter ? (
<CustomFooter
connected={connected}
mode={currentMode}
model={currentModel}
streamingState={streamingState}
contextUsageRatio={
(connection.contextWindow ?? 0) > 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}
/>
) : (
<StatusBar
onSelectMode={() =>
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 && (
<div className={styles.emptyWelcomeFooter}>
{welcomeFooter}
</div>
)}
</div>
</WebShellCustomizationProvider>
</div>
</div>
</div>
</div>

View file

@ -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 ~23-column trends instead of a squeezed 4-up. */
.chartsGrid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 460px), 1fr));
gap: 16px;
}

View file

@ -333,7 +333,7 @@ function MetricsCharts({ series }: { series: DaemonMetricsSeriesBucket[] }) {
</Card>
);
return (
<div className={styles.grid}>
<div className={`${styles.grid} ${styles.chartsGrid}`}>
{chart('daemon.charts.concurrency', formatCount, [
{
label: t('daemon.charts.activePrompts'),

View file

@ -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);

View file

@ -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,