Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f617847
feat(web-shell): show Settings and Daemon Status as an in-place panel
wenshao Jul 5, 2026
0279d37
Merge remote-tracking branch 'origin/main' into feat/web-shell-inline…
wenshao Jul 5, 2026
0c09836
fix(web-shell): preserve composer draft and refine panel focus/escape
wenshao Jul 5, 2026
31fac68
fix(web-shell): reset panel scroll when switching Settings/Status
wenshao Jul 5, 2026
6f7a6e2
fix(web-shell): restore new-chat vertical centering
wenshao Jul 5, 2026
d346f42
fix(web-shell): restore session-org test destructuring dropped in merge
wenshao Jul 5, 2026
a7b321c
Merge remote-tracking branch 'origin/main' into feat/web-shell-inline…
wenshao Jul 5, 2026
44a36cd
fix(web-shell): surface pending approvals over Settings/Status panel
wenshao Jul 5, 2026
3397f41
test(web-shell): cover AskUserQuestion auto-close; robust panel selector
wenshao Jul 5, 2026
a122011
fix(web-shell): close inline panel when resuming a session
wenshao Jul 5, 2026
6dd9790
fix(web-shell): keep composer dormant while an approval overlay is up
wenshao Jul 5, 2026
e4c5a4b
test(web-shell): cover the Daemon Status panel branch
wenshao Jul 5, 2026
b6c2d18
fix(web-shell): dismiss stacked dialogs on approval, focus overlay, a11y
wenshao Jul 6, 2026
98aea1f
fix(web-shell): restore composer focus after approval resolves; cleanups
wenshao Jul 6, 2026
83ee328
Merge origin/main into feat/web-shell-inline-settings-status-panel
wenshao Jul 6, 2026
95affdf
fix(web-shell): make Settings/Status panel and Scheduled Tasks mutual…
wenshao Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,100 @@
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;
}

/* Fill the chat pane width and left-align the content (like the chat's wide
mode) instead of centering it in a narrow column — the Settings grid and the
Daemon Status card grid both expand to use the available width. */
.panelBodyInner {
width: 100%;
}

/* 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
Comment thread
wenshao marked this conversation as resolved.
Resolved
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));
Expand Down
188 changes: 186 additions & 2 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type ChatEditorTestProps = {
commitAccepted?: () => void,
) => boolean | void;
isPreparing?: boolean;
dialogOpen?: boolean;
};

const {
Expand Down Expand Up @@ -70,6 +71,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: [] }),
Expand Down Expand Up @@ -165,12 +167,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: vi.fn(),
}));
return React.createElement(
'button',
Expand Down Expand Up @@ -217,9 +223,26 @@ vi.mock('./components/MessageList', async () => {
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',
),
);
},
};
});
Expand Down Expand Up @@ -306,6 +329,38 @@ async function clickSubmit(container: HTMLElement): Promise<void> {
});
}

// A transcript block shaped like extractPendingPermission() expects. Defaults to
Comment thread
wenshao marked this conversation as resolved.
Resolved
// 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,
Expand Down Expand Up @@ -345,6 +400,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);
Expand Down Expand Up @@ -575,6 +631,134 @@ 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('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('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 });
Expand Down
Loading
Loading