mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-24 16:35:01 +00:00
* 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.
449 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|