qwen-code/packages/web-shell/client/components/SessionOverviewPanel.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

449 lines
15 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useConnection,
useSessions,
useStatusReport,
} from '@qwen-code/webui/daemon-react-sdk';
import type {
DaemonSessionGroupColor,
DaemonSessionSummary,
DaemonStatusReportSession,
} from '@qwen-code/sdk/daemon';
import { useI18n } from '../i18n';
import { formatRelativeTime } from '../utils/formatRelativeTime';
import { buildSplitUrl, MAX_SPLIT_PANES } from '../utils/splitUrl';
import { getDaemonToken } from '../config/daemon';
import {
SESSION_LIST_PAGE_SIZE,
SESSION_ORGANIZATION_FEATURE,
} from '../constants/sessions';
import { ErrorBoundary } from './ErrorBoundary';
import styles from './SessionOverviewPanel.module.css';
// The list is cheap to poll (it's the same endpoint the sidebar already hits),
// so it drives the primary running/idle liveness at a snappy cadence. The
// detail=full status report is materially more expensive — it aggregates
// per-session diagnostics and can spawn the ACP child — so DaemonStatusDialog
// deliberately never polls it. We do poll it here, but slowly: it is the only
// source of the per-session "needs approval" signal, which is the whole point
// of a mission-control view, and a bounded 10s cadence keeps the cost in check
// while approval badges stay live-enough. Both polls pause when the tab is
// hidden or a previous request is still outstanding.
const LIST_POLL_MS = 3000;
const STATUS_POLL_MS = 10000;
export type SessionCardStatus = 'needsApproval' | 'running' | 'idle';
export interface SessionCard {
sessionId: string;
label: string;
status: SessionCardStatus;
clientCount: number;
model?: string;
updatedAt?: string;
color?: DaemonSessionGroupColor | null;
isCurrent: boolean;
}
const STATUS_PRIORITY: Record<SessionCardStatus, number> = {
needsApproval: 0,
running: 1,
idle: 2,
};
/**
* Merge the (cheap, all-sessions) list with the (richer, loaded-sessions-only)
* status report into one ranked set of cards. `needsApproval` is derived from
* the status report's `pendingPermissionCount` and takes precedence over
* `running` because it is the actionable state — the session is blocked waiting
* for the user. Cold sessions absent from the status report simply read as
* idle. Sorted needs-approval → running → idle, then most-recent first, so the
* sessions that want attention float to the top of a 10+ session grid.
*/
export function deriveSessionCards(
sessions: DaemonSessionSummary[],
statusSessions: DaemonStatusReportSession[],
currentSessionId: string | undefined,
): SessionCard[] {
const statusById = new Map(
statusSessions.map((session) => [session.sessionId, session]),
);
const cards = sessions.map((session): SessionCard => {
const status = statusById.get(session.sessionId);
const running = session.hasActivePrompt ?? status?.hasActivePrompt ?? false;
const needsApproval = (status?.pendingPermissionCount ?? 0) > 0;
return {
sessionId: session.sessionId,
label: session.displayName?.trim() || session.sessionId.slice(0, 8),
status: needsApproval ? 'needsApproval' : running ? 'running' : 'idle',
clientCount: session.clientCount ?? status?.clientCount ?? 0,
model: status?.currentModelId,
updatedAt: session.updatedAt || session.createdAt,
color: session.color,
isCurrent: session.sessionId === currentSessionId,
};
});
cards.sort((a, b) => {
const byStatus = STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status];
if (byStatus !== 0) return byStatus;
// ISO timestamps sort lexicographically; newest first.
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
});
return cards;
}
function cx(...classes: Array<string | false | undefined>): string {
return classes.filter(Boolean).join(' ');
}
function colorDotClass(color: DaemonSessionGroupColor): string | undefined {
switch (color) {
case 'red':
return styles.colorRed;
case 'orange':
return styles.colorOrange;
case 'yellow':
return styles.colorYellow;
case 'green':
return styles.colorGreen;
case 'blue':
return styles.colorBlue;
case 'purple':
return styles.colorPurple;
default:
return undefined;
}
}
function statusClass(status: SessionCardStatus): string {
switch (status) {
case 'needsApproval':
return styles.statusApproval;
case 'running':
return styles.statusRunning;
default:
return styles.statusIdle;
}
}
function SessionOverviewPanelInner({
onOpenSession,
onOpenSplit,
}: {
onOpenSession: (sessionId: string) => void;
onOpenSplit?: (sessionIds: string[]) => void;
}) {
const { t } = useI18n();
const connection = useConnection();
const currentSessionId = connection.sessionId;
const organizationEnabled =
connection.capabilities?.features?.includes(
SESSION_ORGANIZATION_FEATURE,
) ?? false;
const { sessions, loading, error, reload } = useSessions({
autoLoad: true,
pageSize: SESSION_LIST_PAGE_SIZE,
archiveState: 'active',
...(organizationEnabled
? { view: 'organized' as const, group: 'all' }
: {}),
});
const status = useStatusReport({ autoLoad: true, detail: 'full' });
const statusReload = status.reload;
const statusReport = status.report;
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [popupBlocked, setPopupBlocked] = useState(false);
// Poll the cheap list. Skip a tick when the tab is hidden or the previous
// request is still outstanding (mirrors the sidebar / daemon-status polls).
const listInFlight = useRef(false);
useEffect(() => {
const timer = window.setInterval(() => {
if (document.hidden || listInFlight.current) return;
listInFlight.current = true;
void reload().finally(() => {
listInFlight.current = false;
});
}, LIST_POLL_MS);
return () => window.clearInterval(timer);
}, [reload]);
// Poll the richer status report less often — it is the only source of
// per-session "needs approval" and current-model, but costs more to build.
const statusInFlight = useRef(false);
useEffect(() => {
const timer = window.setInterval(() => {
if (document.hidden || statusInFlight.current) return;
statusInFlight.current = true;
void statusReload().finally(() => {
statusInFlight.current = false;
});
}, STATUS_POLL_MS);
return () => window.clearInterval(timer);
}, [statusReload]);
const cards = useMemo(
() =>
deriveSessionCards(
sessions,
statusReport?.full?.sessions ?? [],
currentSessionId,
),
[sessions, statusReport, currentSessionId],
);
const toggleSelected = useCallback((sessionId: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) next.delete(sessionId);
else next.add(sessionId);
return next;
});
}, []);
// Selection drives the batch actions: open the checked sessions each in a new
// tab, or open them together in the in-window split.
const selectedIds = cards
.map((card) => card.sessionId)
.filter((id) => selected.has(id));
const selectedCount = selectedIds.length;
const allSelected = cards.length > 0 && selectedCount === cards.length;
// The split shows at most MAX_SPLIT_PANES; cap what we hand off so the new-tab
// URL doesn't bloat with ids that get discarded and the in-window path doesn't
// silently open fewer than were checked. The top-ranked selections win.
const splitIds = selectedIds.slice(0, MAX_SPLIT_PANES);
const overCap = selectedCount > MAX_SPLIT_PANES;
const toggleSelectAll = useCallback(() => {
setSelected((prev) => {
const ids = cards.map((card) => card.sessionId);
// Toggle off only when every currently-listed card is selected — using
// the intersection, not prev.size, so stale ids can't skew it.
const everySelected = ids.length > 0 && ids.every((id) => prev.has(id));
return everySelected ? new Set() : new Set(ids);
});
}, [cards]);
// Drop selections for sessions that have left the list, so a reappearing
// session isn't silently pre-selected and select-all/counts stay accurate.
useEffect(() => {
setSelected((prev) => {
if (prev.size === 0) return prev;
const present = new Set(cards.map((card) => card.sessionId));
let changed = false;
const next = new Set<string>();
for (const id of prev) {
if (present.has(id)) next.add(id);
else changed = true;
}
return changed ? next : prev;
});
}, [cards]);
// Open the selected sessions as a split view in a NEW browser tab: one tab
// showing all of them side by side (not one tab per session). Passing no
// window features makes browsers open a tab rather than a popup window.
const openSelectedInNewTab = useCallback(() => {
if (splitIds.length === 0) return;
// Carry the (already-stripped-from-the-URL) daemon token so the new tab can
// authenticate on token-auth deployments.
const url = buildSplitUrl(splitIds, window.location.href, getDaemonToken());
const win = window.open(url, '_blank');
if (win) {
// The split tab carries a daemon token in its URL fragment; sever the
// opener link so it can't script the shell that spawned it during an
// authenticated session (reverse tabnabbing). Mirrors the bug-report path.
win.opener = null;
win.focus();
}
setPopupBlocked(!win);
}, [splitIds]);
const refresh = useCallback(() => {
void reload();
void statusReload();
}, [reload, statusReload]);
if (cards.length === 0) {
return (
<div className={styles.panel}>
<div className={styles.empty}>
{loading
? t('sessionsOverview.loading')
: error
? `${t('sessionsOverview.loadFailed')}: ${error.message}`
: t('sessionsOverview.empty')}
</div>
</div>
);
}
return (
<div className={styles.panel}>
<div className={styles.toolbar}>
<span className={styles.count}>
{t('sessionsOverview.count', { count: cards.length })}
</span>
<label className={styles.selectAll}>
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
/>
{t('sessionsOverview.selectAll')}
</label>
<button
type="button"
className={styles.actionButton}
disabled={selectedCount === 0}
onClick={openSelectedInNewTab}
title={t('sessionsOverview.openInTabHint')}
>
{t('sessionsOverview.openInTab')}
</button>
{onOpenSplit && (
<button
type="button"
className={styles.actionButton}
disabled={selectedCount === 0}
onClick={() => onOpenSplit(splitIds)}
title={t('sessionsOverview.openInSplitHint')}
>
{t('sessionsOverview.openInSplit')}
</button>
)}
<button
type="button"
className={styles.refreshButton}
onClick={refresh}
>
{t('sessionsOverview.refresh')}
</button>
</div>
{overCap && (
<div className={styles.notice} role="status">
{t('sessionsOverview.splitCap', { max: MAX_SPLIT_PANES })}
</div>
)}
{popupBlocked && (
<div className={styles.notice} role="alert">
{t('sessionsOverview.popupBlocked')}
</div>
)}
{/* Cards are already on screen (empty-state error path didn't run), so a
later failed refresh would otherwise be silent — surface it inline and
keep showing the last-good cards below. */}
{error && (
<div className={styles.notice} role="alert">
{t('sessionsOverview.loadFailed')}: {error.message}
</div>
)}
<ul className={styles.grid}>
{cards.map((card) => (
<li
key={card.sessionId}
className={cx(
styles.card,
statusClass(card.status),
card.isCurrent && styles.cardCurrent,
)}
>
<div className={styles.cardTop}>
<input
type="checkbox"
className={styles.cardCheckbox}
checked={selected.has(card.sessionId)}
onChange={() => toggleSelected(card.sessionId)}
aria-label={t('sessionsOverview.selectSession', {
name: card.label,
})}
/>
{card.color && (
<span
className={cx(styles.colorDot, colorDotClass(card.color))}
aria-hidden="true"
/>
)}
<button
type="button"
className={styles.cardLabel}
onClick={() => onOpenSession(card.sessionId)}
title={card.label}
>
{card.label}
</button>
{card.isCurrent && (
<span className={styles.currentBadge}>
{t('sessionsOverview.current')}
</span>
)}
</div>
<div className={styles.cardMeta}>
<span className={cx(styles.statusBadge, statusClass(card.status))}>
{t(`sessionsOverview.status.${card.status}`)}
</span>
{card.model && (
<span className={styles.metaItem} title={card.model}>
{card.model}
</span>
)}
{card.clientCount > 0 && (
<span
className={styles.metaItem}
title={t('sessionsOverview.openElsewhere')}
>
{t('common.clients', { count: card.clientCount })}
</span>
)}
{card.updatedAt && (
<span className={styles.metaItem}>
{formatRelativeTime(card.updatedAt, t)}
</span>
)}
</div>
</li>
))}
</ul>
</div>
);
}
/**
* A malformed daemon payload must not white-screen the shell; contain any
* render throw to the panel, mirroring DaemonStatusDialog.
*/
export function SessionOverviewPanel({
onOpenSession,
onOpenSplit,
}: {
onOpenSession: (sessionId: string) => void;
onOpenSplit?: (sessionIds: string[]) => void;
}) {
const { t } = useI18n();
return (
<ErrorBoundary
label="session-overview"
fallback={(fallbackError) => (
<div className={styles.panel}>
<div className={styles.empty}>
{t('sessionsOverview.loadFailed')}: {fallbackError.message}
</div>
</div>
)}
>
<SessionOverviewPanelInner
onOpenSession={onOpenSession}
onOpenSplit={onOpenSplit}
/>
</ErrorBoundary>
);
}