Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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.
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
68 changes: 68 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,29 @@ async function clickSubmit(container: HTMLElement): Promise<void> {
});
}

// A transcript block shaped like extractPendingPermission() expects, for a
// non-AskUserQuestion tool so it resolves to a pendingToolApproval.
function makePendingPermissionBlock(
overrides: { resolved?: boolean } = {},
): unknown {
return {
kind: 'permission',
resolved: overrides.resolved ?? false,
requestId: 'req-1',
sessionId: 'session-1',
title: 'Run ls',
toolCall: {
toolCallId: 'tc-1',
kind: 'execute',
_meta: { toolName: 'run_shell_command' },
},
options: [
{ optionId: 'proceed_once', label: 'Allow', raw: {} },
{ optionId: 'cancel', label: 'Reject', raw: {} },
],
};
}

beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
Expand Down Expand Up @@ -575,6 +598,51 @@ 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 only <section> in
// the app is the panel host, so its presence tracks the panel).
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('section')).not.toBeNull();
Comment thread
wenshao marked this conversation as resolved.
Outdated

// A gated tool call arrives.
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});

expect(container.querySelector('section')).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('section')).not.toBeNull();

await act(async () => {
testState.blocks = [makePendingPermissionBlock({ resolved: true })];
rerender();
await Promise.resolve();
});

expect(container.querySelector('section')).not.toBeNull();
});

it('dispatches rename only after the current session name changes', async () => {
const onSessionChange = vi.fn();
const { rerender } = renderApp({ onSessionChange });
Expand Down
Loading
Loading