qwen-code/packages/web-shell/client/components/SessionOverviewPanel.test.tsx
Shaojin Wen f41e95ac18
feat(web-shell): add Session Overview panel and in-window split view (#6400)
* feat(web-shell): add Session Overview panel and in-window split view

Add a large-screen "Session Overview" mission-control panel and an
in-window split view so users can monitor and drive multiple daemon
sessions at once.

- SessionOverviewPanel: ranked live cards (needs-approval -> running ->
  idle) merging the workspace session list with the detail=full status
  report. Multi-select opens the selected sessions as a split view in
  the current tab ("Open in split") or in a new browser tab ("Open in
  new tab", via a ?split=a,b URL).
- SplitView + ChatPane: one DaemonWorkspaceProvider hosting N
  DaemonSessionProvider panes, each a self-contained interactive chat
  (transcript, composer, streaming, tool/ask approvals). Browser focus
  scopes the keyboard per pane, so panes never contend over approvals.
- Sidebar entry points gated to large screens; the split view's Back
  returns to the Session Overview.

* refactor(web-shell): address review feedback on the session overview / split view

- SessionOverviewPanel: prune the selection Set when a session leaves the list
  (so a reappearing session isn't silently reselected) and make select-all use
  the intersection rather than prev.size.
- Extract isAskUserPermission into a shared util so App.tsx and ChatPane.tsx no
  longer keep verbatim copies that can drift.
- SplitView: dismiss the "add session" picker on Escape or a click outside it.
- Tests: MAX_PANES cap, popup-blocked path, checkbox-selects-without-navigating,
  stale-selection pruning, and a direct test for the extracted util.

* fix(web-shell): address /review findings on the split view

- ToolApproval: add a `keyboardActive` prop; split panes pass false so global
  Enter/Escape/digit shortcuts can't confirm the wrong session's approval, and
  the outer session's approval overlay is no longer rendered behind the split
  (where it would keep its global shortcuts while hidden).
- ChatPane: defer the composer commit until sendPrompt resolves, so a rejected
  prompt (transcript loading / disconnected / turn active) preserves the draft
  instead of silently dropping it.
- SplitView: include a per-mount nonce in each pane's clientId so two tabs
  opening the same split don't share a client id — which suppressOwnUserEcho
  would treat as a self-echo and drop from the transcript.
- SessionOverviewPanel: cap the split selection to MAX_SPLIT_PANES before
  building the ?split= URL or opening the in-window split, with a hint when more
  are selected; also dismiss the split picker on Escape / click-outside.
- Tests covering each.

* fix(web-shell): address second /review round on the split view

- SplitView: wrap each pane in its own ErrorBoundary, so a render crash in one
  pane (malformed block, unexpected tool shape) shows an inline fallback with a
  close action instead of white-screening the whole split.
- splitUrl / overview: carry the daemon token into the new-tab split URL's
  fragment. The current tab has already stripped the token from its URL, so a
  token-auth (`serve --open`) deployment would otherwise open the split tab
  unauthenticated. The token rides the hash (never sent to the server / logs).
- Tests: per-pane error isolation, token-in-fragment (and none without a token),
  and the overview polling effects (interval fires, document.hidden skips, and
  the in-flight guard prevents overlapping polls).

* fix(web-shell): hide the outer chat under the split and share app-level contexts

- App: hide (display:none) + aria-hide the outer chat subtree whenever
  mainView !== 'chat', not only when a panel is open. Previously the outer
  chat/composer/toolbar stayed reachable by keyboard/AT behind the full-page
  split (it was only covered visually). State is preserved (node stays mounted).
- App: wrap SplitView in the app-level WebShellCustomizationProvider and
  CompactModeContext so split panes render markdown / tool-headers / thinking
  the same way the single-session chat does. Todo contexts stay chat-only —
  they belong to the outer session, not the panes.

* refactor(web-shell): address review suggestions — coverage, dedup, split UX

- ToolApproval: add a dedicated test on the real component that the global
  keyboard shortcut is armed by default and NOT armed when keyboardActive=false
  (the cross-pane approval safety mechanism).
- SplitView: auto-exit to the Session Overview when the last pane is closed
  (guarded so an initial empty seed doesn't bounce straight back out).
- ChatPane: add tests for the cancel action, the empty/whitespace submit guard,
  and error routing to the onError prop.
- Extract the shared session-list page size + organization feature flag into
  constants/sessions.ts, used by the overview, split view, and sidebar, so the
  values can't drift between the three.

* fix(web-shell): surface outer approval + failed refresh in overview/split

- Split view: when the outer (main) session is waiting on an approval
  that's hidden behind the split, show a non-blocking notice banner with
  a "Go to it" button that returns to the chat where the approval lives.
- Auto-close the split (like the overview panel) when the viewport shrinks
  below the large-screen breakpoint, so users aren't stranded.
- Session Overview: surface a failed refresh inline (keeping the last-good
  cards) instead of silently swallowing it once cards are on screen.
- Tests: status-report poll cadence, picker dismiss (Escape / outside /
  inside click), inline refresh-failure banner.

* fix(web-shell): sever window.opener on split tab; tighten hidden-chat test

- openSelectedInNewTab now clears win.opener (the split tab carries a
  daemon token in its URL fragment) to prevent reverse tabnabbing, matching
  the existing bug-report window.open path.
- Strengthen the split-view App test so a missing outer-chat subtree fails
  instead of passing vacuously through an optional chain.

* fix(web-shell): split-view focus/stability/robustness follow-ups

- Refocus the composer after a shrink-driven split close so keyboard users
  aren't dropped onto <body> (skips when an approval or panel takes over).
- Stabilize SplitView onExit via useCallback so its last-pane-close effect
  doesn't re-fire on every App re-render.
- ChatPane: surface a per-pane connection-loss banner instead of silently
  showing stale messages when a pane's daemon connection drops.
- ChatPane: anchor the streaming timer to the active turn's start (last user
  message timestamp) so a pane opened mid-turn shows real elapsed time.
- Tests: split auto-close on shrink, outer-approval split notice + return-to-
  chat, connection banner, and streaming-timer anchoring.
2026-07-07 02:44:18 +00:00

461 lines
16 KiB
TypeScript

// @vitest-environment jsdom
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type {
DaemonSessionSummary,
DaemonStatusReportSession,
} from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../i18n';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// --- Mutable mock state, reset in beforeEach ---
let connectionState: {
sessionId?: string;
capabilities?: { features?: string[] };
workspaceCwd?: string;
};
let sessionsState: {
sessions: DaemonSessionSummary[];
loading: boolean;
error?: Error;
};
let statusState: {
report?: { full?: { sessions: DaemonStatusReportSession[] } };
};
const sessionsReload = vi.fn(async () => sessionsState.sessions);
const statusReload = vi.fn(async () => statusState.report);
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useConnection: () => connectionState,
useSessions: () => ({ ...sessionsState, reload: sessionsReload }),
useStatusReport: () => ({ ...statusState, reload: statusReload }),
}));
const { SessionOverviewPanel, deriveSessionCards } = await import(
'./SessionOverviewPanel'
);
function session(
id: string,
extra: Partial<DaemonSessionSummary> = {},
): DaemonSessionSummary {
return {
sessionId: id,
workspaceCwd: '/w',
updatedAt: '2026-07-06T10:00:00.000Z',
...extra,
};
}
function statusSession(
id: string,
extra: Partial<DaemonStatusReportSession> = {},
): DaemonStatusReportSession {
return {
sessionId: id,
workspaceCwd: '/w',
createdAt: '2026-07-06T09:00:00.000Z',
clientCount: 0,
subscriberCount: 0,
attachCount: 0,
pendingPromptCount: 0,
pendingPermissionCount: 0,
hasActivePrompt: false,
lastEventId: 0,
...extra,
};
}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let onOpenSession: ReturnType<typeof vi.fn>;
let openSpy: ReturnType<typeof vi.fn>;
const originalOpen = window.open;
beforeEach(() => {
connectionState = {
sessionId: 's-run',
capabilities: { features: [] },
workspaceCwd: '/w',
};
sessionsState = { sessions: [], loading: false };
statusState = { report: { full: { sessions: [] } } };
sessionsReload.mockClear();
statusReload.mockClear();
onOpenSession = vi.fn();
openSpy = vi.fn().mockReturnValue({ focus: vi.fn() });
window.open = openSpy as unknown as typeof window.open;
});
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
window.open = originalOpen;
});
function render(props: { onOpenSplit?: (ids: string[]) => void } = {}): void {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root!.render(
<I18nProvider language="en">
<SessionOverviewPanel onOpenSession={onOpenSession} {...props} />
</I18nProvider>,
),
);
}
function rerender(props: { onOpenSplit?: (ids: string[]) => void } = {}): void {
act(() =>
root!.render(
<I18nProvider language="en">
<SessionOverviewPanel onOpenSession={onOpenSession} {...props} />
</I18nProvider>,
),
);
}
function cardLabels(): string[] {
return Array.from(container!.querySelectorAll('ul li')).map(
(li) => li.querySelectorAll('button')[0]?.textContent?.trim() ?? '',
);
}
function selectAllCheckbox(): HTMLInputElement {
return container!.querySelector('input[type="checkbox"]') as HTMLInputElement;
}
function tabButton(): HTMLButtonElement {
return Array.from(container!.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Open in new tab'),
) as HTMLButtonElement;
}
describe('deriveSessionCards', () => {
it('ranks needs-approval above running above idle, then by recency', () => {
const sessions = [
session('s-idle', { displayName: 'idle', hasActivePrompt: false }),
session('s-run', { displayName: 'run', hasActivePrompt: true }),
session('s-appr', { displayName: 'appr', hasActivePrompt: false }),
];
const status = [statusSession('s-appr', { pendingPermissionCount: 1 })];
const cards = deriveSessionCards(sessions, status, 's-run');
expect(cards.map((c) => c.sessionId)).toEqual(['s-appr', 's-run', 's-idle']);
expect(cards.map((c) => c.status)).toEqual([
'needsApproval',
'running',
'idle',
]);
});
it('needs-approval wins even when the prompt is also active (blocked turn)', () => {
const sessions = [session('s', { hasActivePrompt: true })];
const status = [
statusSession('s', { hasActivePrompt: true, pendingPermissionCount: 2 }),
];
expect(deriveSessionCards(sessions, status, undefined)[0].status).toBe(
'needsApproval',
);
});
it('treats sessions absent from the status report as idle', () => {
const cards = deriveSessionCards([session('cold')], [], undefined);
expect(cards[0].status).toBe('idle');
});
it('labels with displayName, falling back to a short id, and flags current', () => {
const cards = deriveSessionCards(
[
session('abcdef1234567890', {}),
session('named', { displayName: ' Named ' }),
],
[],
'named',
);
const byId = new Map(cards.map((c) => [c.sessionId, c]));
expect(byId.get('abcdef1234567890')!.label).toBe('abcdef12');
expect(byId.get('named')!.label).toBe('Named');
expect(byId.get('named')!.isCurrent).toBe(true);
expect(byId.get('abcdef1234567890')!.isCurrent).toBe(false);
});
it('carries model and client count from the status report', () => {
const cards = deriveSessionCards(
[session('s', { clientCount: 2 })],
[statusSession('s', { currentModelId: 'qwen-max', clientCount: 2 })],
undefined,
);
expect(cards[0].model).toBe('qwen-max');
expect(cards[0].clientCount).toBe(2);
});
});
describe('SessionOverviewPanel', () => {
it('renders an empty state when there are no sessions', () => {
render();
expect(container!.textContent).toContain('No sessions yet');
});
it('renders cards ranked with needs-approval first', () => {
sessionsState.sessions = [
session('s-idle', { displayName: 'Bravo' }),
session('s-run', { displayName: 'Alpha', hasActivePrompt: true }),
session('s-appr', { displayName: 'Charlie' }),
];
statusState.report = {
full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] },
};
render();
expect(cardLabels()).toEqual(['Charlie', 'Alpha', 'Bravo']);
});
it('switches the current session when a card label is clicked', () => {
sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })];
render();
const label = container!.querySelector('ul li button') as HTMLButtonElement;
act(() => label.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onOpenSession).toHaveBeenCalledWith('s-run');
});
it('always shows selection + the "Open in new tab" batch action', () => {
sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })];
render();
expect(container!.querySelector('input[type="checkbox"]')).not.toBeNull();
expect(container!.textContent).toContain('Open in new tab');
// The removed Window Management tiling affordance is gone.
expect(container!.textContent).not.toContain('Tile across displays');
});
it('opens the selected sessions as a split in ONE new tab (?split=…)', () => {
sessionsState.sessions = [
session('s-idle', { displayName: 'Bravo' }),
session('s-appr', { displayName: 'Charlie' }),
];
statusState.report = {
full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] },
};
render();
const selectAll = container!.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
act(() => selectAll.click());
const tabButton = Array.from(container!.querySelectorAll('button')).find(
(b) => b.textContent?.includes('Open in new tab'),
) as HTMLButtonElement;
expect(tabButton.disabled).toBe(false);
act(() =>
tabButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
// A single new tab whose URL carries the ranked split (needs-approval first).
expect(openSpy).toHaveBeenCalledTimes(1);
const [url, target] = openSpy.mock.calls[0];
expect(target).toBe('_blank');
expect(decodeURIComponent(String(url))).toContain('split=s-appr,s-idle');
});
it('severs window.opener on the new split tab (token is in its URL)', () => {
sessionsState.sessions = [session('s1', { displayName: 'One' })];
const opened = { focus: vi.fn(), opener: {} as unknown };
openSpy.mockReturnValue(opened);
render();
act(() => selectAllCheckbox().click());
act(() =>
tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
// The opener link is cut so the authenticated split tab can't script us.
expect(opened.opener).toBeNull();
expect(opened.focus).toHaveBeenCalledTimes(1);
});
it('opens the selected sessions in split, in ranked order', () => {
sessionsState.sessions = [
session('s-idle', { displayName: 'Bravo' }),
session('s-appr', { displayName: 'Charlie' }),
];
statusState.report = {
full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] },
};
const onOpenSplit = vi.fn();
render({ onOpenSplit });
const selectAll = container!.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
act(() => selectAll.click());
const splitButton = Array.from(container!.querySelectorAll('button')).find(
(b) => b.textContent?.includes('Open in split'),
) as HTMLButtonElement;
act(() =>
splitButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
// needs-approval (Charlie) is ranked ahead of idle (Bravo).
expect(onOpenSplit).toHaveBeenCalledWith(['s-appr', 's-idle']);
});
it('toggling a card checkbox selects it without switching sessions', () => {
sessionsState.sessions = [session('s-run', { displayName: 'Alpha' })];
render();
const cardCheckbox = container!.querySelector(
'ul li input[type="checkbox"]',
) as HTMLInputElement;
act(() => cardCheckbox.click());
expect(cardCheckbox.checked).toBe(true);
// Selecting must not navigate — that's what the label button is for.
expect(onOpenSession).not.toHaveBeenCalled();
});
it('surfaces the popup-blocked notice when window.open is blocked', () => {
sessionsState.sessions = [session('s1', { displayName: 'One' })];
openSpy.mockReturnValue(null); // browser blocked the pop-up
render();
act(() => selectAllCheckbox().click());
act(() =>
tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(container!.textContent).toContain('Pop-up blocked');
});
it('surfaces a refresh failure inline while keeping the last-good cards', () => {
sessionsState.sessions = [session('s1', { displayName: 'One' })];
sessionsState.error = new Error('daemon unreachable');
render();
// The cards stay on screen (not replaced by the empty/error-only state)…
expect(cardLabels()).toEqual(['One']);
// …and the failure is still visible rather than silently swallowed.
expect(container!.textContent).toContain('daemon unreachable');
});
it('does not re-select a session that left the list and came back', () => {
sessionsState.sessions = [
session('a', { displayName: 'A' }),
session('b', { displayName: 'B' }),
];
render();
act(() => selectAllCheckbox().click()); // a + b selected
expect(selectAllCheckbox().checked).toBe(true);
// 'a' leaves the list → its selection is pruned.
sessionsState.sessions = [session('b', { displayName: 'B' })];
rerender();
// 'a' comes back → it must NOT be pre-selected (only b remains selected).
sessionsState.sessions = [
session('a', { displayName: 'A' }),
session('b', { displayName: 'B' }),
];
rerender();
expect(selectAllCheckbox().checked).toBe(false);
act(() =>
tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
// Only 'b' is opened — the returned 'a' was pruned, not silently reselected.
const url = new URL(String(openSpy.mock.calls[0][0]));
expect(url.searchParams.get('split')).toBe('b');
});
it('caps the split to 6 sessions and warns when more are selected', () => {
sessionsState.sessions = Array.from({ length: 8 }, (_, i) =>
session(`s${i}`, { displayName: `S${i}` }),
);
const onOpenSplit = vi.fn();
render({ onOpenSplit });
act(() => selectAllCheckbox().click()); // all 8 selected
// A hint tells the user only the first 6 will open.
expect(container!.textContent).toContain('Only the first 6');
// New-tab URL carries at most 6 ids.
act(() =>
tabButton().dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
const split = new URL(String(openSpy.mock.calls[0][0])).searchParams.get(
'split',
);
expect(split!.split(',')).toHaveLength(6);
// In-window split is likewise capped.
const splitButton = Array.from(container!.querySelectorAll('button')).find(
(b) => b.textContent?.includes('Open in split'),
) as HTMLButtonElement;
act(() =>
splitButton.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(onOpenSplit.mock.calls[0][0]).toHaveLength(6);
});
});
describe('SessionOverviewPanel polling', () => {
it('polls the session list on an interval', async () => {
sessionsState.sessions = [session('s')];
vi.useFakeTimers();
try {
render();
sessionsReload.mockClear();
await vi.advanceTimersByTimeAsync(3100);
expect(sessionsReload).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('skips polling while the tab is hidden', async () => {
sessionsState.sessions = [session('s')];
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => true,
});
vi.useFakeTimers();
try {
render();
sessionsReload.mockClear();
await vi.advanceTimersByTimeAsync(6200);
expect(sessionsReload).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
}
});
it('does not overlap polls while one is still in flight', async () => {
sessionsState.sessions = [session('s')];
// A never-resolving reload keeps the in-flight guard set.
sessionsReload.mockImplementation(() => new Promise<never>(() => {}));
vi.useFakeTimers();
try {
render();
sessionsReload.mockClear();
await vi.advanceTimersByTimeAsync(9300); // three list ticks
expect(sessionsReload).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
sessionsReload.mockReset();
sessionsReload.mockImplementation(async () => sessionsState.sessions);
}
});
it('polls the richer status report on its own slower interval', async () => {
sessionsState.sessions = [session('s')];
vi.useFakeTimers();
try {
render();
statusReload.mockClear();
// One list tick (3s) must NOT drive a status refresh…
await vi.advanceTimersByTimeAsync(3100);
expect(statusReload).not.toHaveBeenCalled();
// …but crossing the 10s status cadence does, exactly once.
await vi.advanceTimersByTimeAsync(7000);
expect(statusReload).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});