Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,34 @@
padding: 14px 20px;
border-bottom: 1px solid var(--border);
}
/* Non-blocking banner shown at the top of the split when the outer (main)
session is waiting on an approval that's hidden behind the split view. */
.splitApprovalNotice {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: var(--warning-bg);
border-bottom: 1px solid var(--warning-border);
color: var(--warning-color);
font-size: 13px;
}
.splitApprovalNotice button {
appearance: none;
margin-left: auto;
border: 1px solid var(--warning-border);
background: transparent;
color: inherit;
cursor: pointer;
padding: 3px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.splitApprovalNotice button:hover {
background: var(--warning-border);
}
.fullPageBack {
appearance: none;
border: none;
Expand Down
289 changes: 284 additions & 5 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,12 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
WebShellSidebar: (props: {
sessionListReloadToken?: number;
onOpenDaemonStatus?: () => void;
onOpenSessions?: () => void;
onOpenSplitView?: () => void;
}) => {
sidebarTokens.push(props.sessionListReloadToken);
// Expose the Daemon Status opener so tests can exercise the
// activePanel === 'status' branch (there is no slash command for it).
// Expose the Daemon Status / Session Overview openers so tests can
// exercise those activePanel branches (neither has a slash command).
return React.createElement(
'div',
{ 'data-testid': 'sidebar' },
Expand All @@ -305,6 +307,24 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
},
'daemon status',
),
React.createElement(
'button',
{
'data-testid': 'open-sessions-overview',
type: 'button',
onClick: props.onOpenSessions,
},
'sessions overview',
),
React.createElement(
'button',
{
'data-testid': 'open-split-view',
type: 'button',
onClick: props.onOpenSplitView,
},
'split view',
),
);
},
};
Expand All @@ -328,6 +348,22 @@ mockComponent('./components/dialogs/ApprovalModeDialog', 'ApprovalModeDialog');
mockComponent('./components/dialogs/ResumeDialog', 'ResumeDialog');
mockComponent('./components/dialogs/ToolsDialog', 'ToolsDialog');
mockComponent('./components/dialogs/DaemonStatusDialog', 'DaemonStatusDialog');
mockComponent('./components/SessionOverviewPanel', 'SessionOverviewPanel');
vi.doMock('./components/SplitView', async () => {
const React = await import('react');
return {
SplitView: (props: { onExit?: () => void }) =>
React.createElement(
'div',
{ 'data-testid': 'split-view-mock' },
React.createElement(
'button',
{ 'data-testid': 'split-back', type: 'button', onClick: props.onExit },
'back',
),
),
};
});
mockComponent(
'./components/dialogs/ScheduledTasksDialog',
'ScheduledTasksDialog',
Expand Down Expand Up @@ -428,11 +464,15 @@ function makePendingPermissionBlock(
beforeEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
// Query-aware: report a large screen (min-width matches) so the Session
// Overview entry point is available, while keeping the mobile (max-width)
// query false as the other tests expect.
value: vi.fn().mockImplementation((query: string) => ({
matches: typeof query === 'string' && query.includes('min-width'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
})),
});
mockConnection.sessionId = 'session-1';
mockConnection.status = 'connected';
Expand Down Expand Up @@ -925,6 +965,245 @@ describe('App session callbacks', () => {
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});

it('opens the Session Overview panel from the sidebar', async () => {
const { container } = renderApp();
await flush();

await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="open-sessions-overview"]',
)
?.click();
await Promise.resolve();
});
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
// The panelHost aria-label distinguishes which panel is up.
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});

it('opens the split view from the sidebar', async () => {
const { container } = renderApp();
await flush();

await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
// The outer chat subtree is hidden (display:none + aria-hidden) behind the
// split, so keyboard/AT can't reach the outer composer/toolbar. Assert the
// node is present first, so a missing subtree fails rather than passing
// vacuously through the optional chain.
const messages = container.querySelector('[data-testid="messages"]');
expect(messages).not.toBeNull();
expect(messages?.closest('[aria-hidden="true"]')).not.toBeNull();
});

it('returns to the Session Overview when leaving the split view', async () => {
const { container } = renderApp();
await flush();

await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();

await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
// Split closed; the Session Overview panel is shown instead of the chat.
expect(container.querySelector('[data-testid="split-view-page"]')).toBeNull();
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});

it('enters the split view from a ?split= URL and consumes the param', async () => {
window.history.pushState({}, '', '/?split=s1,s2');
try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
// The one-shot param is stripped so a reload/exit doesn't force it back.
expect(window.location.search).toBe('');
} finally {
window.history.pushState({}, '', '/');
}
});

it('keeps the split view open when an approval becomes pending (unlike the scheduled-tasks page)', async () => {
// Each split pane owns its own session's approval, so an approval on the
// outer main session must NOT yank the user out of the split.
const { container, rerender } = renderApp();
await flush();

await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();

await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
// The outer session's approval overlay must NOT render behind the split —
// otherwise its global keyboard shortcuts could confirm an unseen approval.
expect(
container.querySelector('[data-testid="approval-overlay"]'),
).toBeNull();
});

it('surfaces the outer approval as a split notice and returns to chat when clicked', async () => {
// The overlay is suppressed under the split, so the outer approval would be
// invisible; a notice banner (with a way back) is the only signal.
const { container, rerender } = renderApp();
await flush();

await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
const notice = container.querySelector(
'[data-testid="split-approval-notice"]',
);
expect(notice).not.toBeNull();
// Its button leaves the split (mainView -> 'chat') so the approval overlay,
// which only renders in chat, becomes visible and actionable.
await act(async () => {
notice!
.querySelector('button')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="approval-overlay"]'),
).not.toBeNull();
});

it('auto-closes the split view when the screen shrinks below the breakpoint', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('min-width')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});

const { container } = renderApp();
await flush();

await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();

await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
// Shrinking below the large-screen breakpoint folds the split back to chat.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});

it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => {
// Drive isLargeScreen through a controllable media query: open the panel on
// a large screen, then flip below the breakpoint and confirm it closes.
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('min-width')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});

const { container } = renderApp();
await flush();

await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="open-sessions-overview"]',
)
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});

it('dismisses the Scheduled Tasks page when an approval becomes pending', async () => {
// The scheduled-tasks fullPage overlay covers the chat footer where the
// approval renders, so an approval must close it too (like the panel).
Expand Down
Loading
Loading