Files
qwen-code/packages/web-shell/client/components/workspaces/WorkspacesOverviewPanel.tsx
591ebc5740 fix(web-shell): pause requests offline and recover automatically (#14980)
* fix(web-shell): pause daemon traffic and recover automatically

* fix(web-shell): use service wording in connection messages

* fix(web-shell): recover loading retries through health checks

* test(web-shell): align mock health response with service contract

* fix(build): account for recovery transcript renderer size

* fix(web-shell): preserve recovery state and offline feedback

* fix(web-shell): address daemon recovery review findings (#14980)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): address daemon recovery review findings (#14980)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): address daemon recovery review findings (#14980)

* fix(web-shell): keep local slash commands usable during an outage (#14980)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: Qwen Autofix <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-10-29 11:02:10 +08:00

598 lines
19 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
useDaemonAvailability,
useDaemonAvailabilityController,
} from '../../daemon/availability-context.js';
import { isDaemonUnavailableError } from '../../daemon/availability.js';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Ref,
} from 'react';
import { ArrowLeftIcon, PlusIcon, Trash2Icon } from 'lucide-react';
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
useConnection,
useWorkspace,
useWorkspaceActions,
} from '@qwen-code/web-shell/daemon-react-sdk';
import type {
DaemonClient,
DaemonWorkspaceCapability,
DaemonWorkspaceGitStatus,
} from '@qwen-code/sdk/daemon';
import { useI18n } from '../../i18n';
import { workspaceLabel } from '../../utils/workspace';
import { formatRelativeTime } from '../../utils/formatRelativeTime';
import {
SESSION_LIST_PAGE_SIZE,
WEB_SHELL_SESSION_SOURCE_TYPE,
} from '../../constants/sessions';
import {
useSessionCatalogController,
useSessionCatalogQuery,
} from '../../session-catalog/session-catalog-hooks';
import { useWorkspaceOverview } from '../sidebar/useWorkspaceOverview';
import { summarizeSessions } from '../sidebar/workspaceOverviewModel';
import { useWorkspaceRemoval } from './useWorkspaceRemoval';
import { WorkspaceRemovalDialog } from './WorkspaceRemovalDialog';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { DataTable, type DataTableColumnMeta } from '../ui/data-table';
import { TooltipProvider } from '../ui/tooltip';
import styles from './WorkspacesOverviewPanel.module.css';
/**
* Counts change on the order of prompts, not keystrokes. The query is
* byte-identical to useOtherWorkspaceSessions', so for secondary workspaces
* the catalog store dedupes the panel with the sidebar's own subscription;
* an expanded row's qualified 10s query is a separate entry.
*/
const SESSIONS_POLL_MS = 30_000;
const GIT_POLL_MS = 60_000;
/**
* A trust grant is written to the daemon's trust file at once, but this table
* reads each workspace's runtime, which the daemon rebuilds asynchronously.
* Poll the capabilities payload until the rebuild shows up, bounded so a
* failed reconcile still returns the row to a clickable state.
*/
const TRUST_APPLY_POLL_MS = 1_000;
const TRUST_APPLY_TIMEOUT_MS = 30_000;
/** One page of the active default catalog, including Qwen Live tasks. */
function useWorkspaceSessionsPage(cwd: string, enabled: boolean) {
const workspace = useWorkspace();
const query = useMemo(
() => ({
routeKind: 'legacy' as const,
workspaceCwd: cwd,
options: {
pageSize: SESSION_LIST_PAGE_SIZE,
archiveState: 'active' as const,
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE,
},
}),
[cwd],
);
return useSessionCatalogQuery(workspace.client, query, {
autoLoad: true,
enabled,
pollIntervalMs: SESSIONS_POLL_MS,
});
}
/**
* Same fetch discipline as the sidebar's git chip: enriched (`wait`) status,
* last known value kept across a transient failure, refreshed on focus and a
* visibility-gated slow poll.
*/
function useWorkspaceGitStatus(
client: DaemonClient,
cwd: string,
enabled: boolean,
) {
const available = useDaemonAvailability() === 'available';
const availability = useDaemonAvailabilityController();
const [status, setStatus] = useState<DaemonWorkspaceGitStatus>();
const failedRef = useRef(false);
const load = useCallback(async () => {
if (
!available ||
!enabled ||
(availability && availability.getSnapshot() !== 'available')
)
return;
try {
const next = await client.workspaceByCwd(cwd).workspaceGit({
wait: true,
});
failedRef.current = false;
setStatus(next);
} catch (err) {
if (
isDaemonUnavailableError(err) ||
(availability && availability.getSnapshot() !== 'available')
)
return;
if (!failedRef.current) {
console.warn('[WorkspacesOverviewPanel] git status failed:', err);
failedRef.current = true;
}
}
}, [availability, available, client, cwd, enabled]);
useEffect(() => {
if (!enabled) {
setStatus(undefined);
return;
}
if (!available) return;
void load();
const onFocus = () => void load();
window.addEventListener('focus', onFocus);
const timer = window.setInterval(() => {
if (document.visibilityState === 'visible') void load();
}, GIT_POLL_MS);
return () => {
window.removeEventListener('focus', onFocus);
window.clearInterval(timer);
};
}, [available, enabled, load]);
return status;
}
function NameCell({ workspace }: { workspace: DaemonWorkspaceCapability }) {
const { t } = useI18n();
return (
<span className={styles.nameCell}>
<span className={styles.nameLabel}>{workspaceLabel(workspace)}</span>
{workspace.primary && (
<Badge variant="secondary">
{t('workspacesOverview.primaryBadge')}
</Badge>
)}
{!workspace.trusted && (
<Badge variant="outline">{t('sidebar.workspaceUntrusted')}</Badge>
)}
</span>
);
}
function SessionsCell({ workspace }: { workspace: DaemonWorkspaceCapability }) {
const { t } = useI18n();
const page = useWorkspaceSessionsPage(workspace.cwd, workspace.trusted);
if (!workspace.trusted || page.page === undefined) {
return <span className={styles.muted}>—</span>;
}
// Either more pages follow, or the daemon capped its scan; the same
// union the sidebar's section applies.
const stats = summarizeSessions(
page.sessions,
page.truncated || Boolean(page.nextCursor),
);
return (
<span
className={styles.statCell}
title={stats.truncated ? t('workspacesOverview.truncated') : undefined}
>
<span>
{stats.total}
{stats.truncated ? '+' : ''}
</span>
{stats.running > 0 && (
<Badge variant="secondary">
{t('workspacesOverview.running', { count: stats.running })}
</Badge>
)}
{stats.attention > 0 && (
<Badge variant="destructive">
{t('workspacesOverview.attention', { count: stats.attention })}
</Badge>
)}
</span>
);
}
function McpCell({ workspace }: { workspace: DaemonWorkspaceCapability }) {
const { t } = useI18n();
const ws = useWorkspace();
const { overview } = useWorkspaceOverview(ws.client, workspace.cwd, {
enabled: workspace.trusted,
items: ['mcp'],
});
const mcp = overview?.mcp;
// Disabled servers are excluded from the denominator, matching the
// sidebar chip's connected/enabled convention (formatOverviewValue).
const enabled = mcp ? mcp.configured - mcp.disabled : 0;
if (!workspace.trusted || !mcp || !mcp.initialized) {
// The runtime discovers MCP servers; before an ACP child is live the
// daemon answers a placeholder that must read as unknown, never zero.
return (
<span className={styles.muted} title={t('sidebar.overview.unknown')}>
—
</span>
);
}
return (
<span className={styles.statCell}>
<span>{enabled === 0 ? '0' : `${mcp.connected}/${enabled}`}</span>
{mcp.failed > 0 && (
<Badge variant="destructive">
{t('workspacesOverview.mcpFailed', { count: mcp.failed })}
</Badge>
)}
</span>
);
}
function GitCell({ workspace }: { workspace: DaemonWorkspaceCapability }) {
const { t } = useI18n();
const ws = useWorkspace();
const status = useWorkspaceGitStatus(
ws.client,
workspace.cwd,
workspace.trusted,
);
if (!workspace.trusted || status === undefined) {
return <span className={styles.muted}>—</span>;
}
if (!status.branch) {
return <span className={styles.muted}>—</span>;
}
const dirty =
(status.staged ?? 0) +
(status.unstaged ?? 0) +
(status.untracked ?? 0) +
(status.conflicted ?? 0);
return (
<span className={styles.statCell}>
<span className={styles.nameLabel}>{status.branch}</span>
{dirty > 0 && (
<span className={styles.muted}>
{t('workspacesOverview.dirty', { count: dirty })}
</span>
)}
</span>
);
}
function LastActivityCell({
workspace,
}: {
workspace: DaemonWorkspaceCapability;
}) {
const { t } = useI18n();
const page = useWorkspaceSessionsPage(workspace.cwd, workspace.trusted);
if (!workspace.trusted || page.page === undefined) {
return <span className={styles.muted}>—</span>;
}
let latest: string | undefined;
for (const session of page.sessions) {
// Mirrors the daemon's getSummaryActivityTime: a live session has no
// updatedAt until its first terminal publishes; createdAt stands in.
const activity = session.updatedAt ?? session.createdAt;
if (activity && (!latest || activity > latest)) {
latest = activity;
}
}
if (!latest) return <span className={styles.muted}>—</span>;
return <span>{formatRelativeTime(latest, t)}</span>;
}
export interface WorkspacesOverviewPanelProps {
onClose: () => void;
/** Starts a new draft in the row's workspace (every row names one). */
onNewSession: (workspaceCwd: string) => Promise<boolean> | boolean;
/** Opens the existing Add-workspace dialog; hidden when not registered. */
onAddWorkspace?: () => void;
onError?: (error: unknown, message: string) => void;
initialFocusRef?: Ref<HTMLHeadingElement>;
}
/**
* Full-page table of every registered workspace: name, path, session counts,
* MCP health, branch and last activity, with the per-row actions the sidebar
* offers in its `⋮` menu. Layer B2 of the workspace-overview plan.
*/
export function WorkspacesOverviewPanel({
onClose,
onNewSession,
onAddWorkspace,
onError,
initialFocusRef,
}: WorkspacesOverviewPanelProps) {
const { t } = useI18n();
const connection = useConnection();
const workspace = useWorkspace();
const workspaceActions = useWorkspaceActions();
const catalogController = useSessionCatalogController(workspace.client);
const [creatingCwd, setCreatingCwd] = useState<string | null>(null);
const [trustPendingCwd, setTrustPendingCwd] = useState<string | null>(null);
const [trustTick, setTrustTick] = useState(0);
const trustDeadlineRef = useRef(0);
// The workspace context value is rebuilt whenever capabilities change, so a
// callback closing over it would rebuild the column definitions (and remount
// every cell) on each poll.
const workspaceRef = useRef(workspace);
useEffect(() => {
workspaceRef.current = workspace;
}, [workspace]);
const workspaces = useMemo(
() =>
(workspace.capabilities?.workspaces ?? []).filter(
(entry) => entry.kind !== 'live',
),
[workspace.capabilities?.workspaces],
);
const removalEnabled = Boolean(
connection.capabilities?.features?.includes('workspace_runtime_removal'),
);
const trustGrantEnabled = Boolean(
connection.capabilities?.features?.includes('workspace_trust_grant'),
);
const reportError = useCallback(
(error: unknown, message: string) => {
if (onError) onError(error, message);
else console.error(message, error);
},
[onError],
);
const removal = useWorkspaceRemoval({
removeWorkspace: (workspaceId, options) =>
workspaceActions.removeWorkspace(workspaceId, options),
onRemoved: async (removed) => {
catalogController.invalidateWorkspace(removed.cwd);
try {
await workspace.refreshCapabilities?.();
} catch {
// The mutation already converged; a later refresh will reconcile,
// same contract as the sidebar's reconcileRemovedWorkspace.
}
},
onError: reportError,
errorMessage: t('sidebar.removeWorkspaceError'),
blockForce: (candidate) =>
Boolean(connection.sessionId) &&
connection.workspaceCwd === candidate.cwd,
});
const handleNewSession = useCallback(
(ws: DaemonWorkspaceCapability) => {
if (creatingCwd !== null) return;
setCreatingCwd(ws.cwd);
void (async () => {
try {
await onNewSession(ws.cwd);
} finally {
setCreatingCwd(null);
}
})();
},
[creatingCwd, onNewSession],
);
const handleGrantTrust = useCallback(
(ws: DaemonWorkspaceCapability) => {
if (trustPendingCwd !== null) return;
trustDeadlineRef.current = Date.now() + TRUST_APPLY_TIMEOUT_MS;
setTrustPendingCwd(ws.cwd);
void (async () => {
try {
await workspaceRef.current.client
.workspaceByCwd(ws.cwd)
.grantWorkspaceTrust();
} catch (error) {
setTrustPendingCwd(null);
reportError(error, t('workspacesOverview.trustFailed'));
return;
}
void workspaceRef.current.refreshCapabilities?.().catch(() => {});
})();
},
[reportError, t, trustPendingCwd],
);
// Clears once the granted workspace reports trusted, or at the deadline.
// The timer re-arms itself rather than relying on the polled payload to
// change: a refresh that fails leaves `workspaces` untouched, and the
// deadline must still be reached so the row becomes clickable again.
useEffect(() => {
if (trustPendingCwd === null) return;
const entry = workspaces.find((ws) => ws.cwd === trustPendingCwd);
if (entry?.trusted || Date.now() >= trustDeadlineRef.current) {
setTrustPendingCwd(null);
return;
}
const timer = setTimeout(() => {
void workspaceRef.current.refreshCapabilities?.().catch(() => {});
setTrustTick((tick) => tick + 1);
}, TRUST_APPLY_POLL_MS);
return () => clearTimeout(timer);
}, [trustPendingCwd, trustTick, workspaces]);
const columns = useMemo<ColumnDef<DaemonWorkspaceCapability>[]>(
() => [
{
id: 'name',
header: t('workspacesOverview.column.name'),
cell: ({ row }) => <NameCell workspace={row.original} />,
meta: {
width: 180,
fluidWeight: 2,
} satisfies DataTableColumnMeta<DaemonWorkspaceCapability>,
},
{
id: 'path',
header: t('workspacesOverview.column.path'),
cell: ({ row }) => (
<span className={styles.pathCell} title={row.original.cwd}>
{row.original.cwd}
</span>
),
meta: {
width: 220,
fluidWeight: 3,
} satisfies DataTableColumnMeta<DaemonWorkspaceCapability>,
},
{
id: 'sessions',
header: t('workspacesOverview.column.sessions'),
cell: ({ row }) => <SessionsCell workspace={row.original} />,
meta: {
width: 150,
} satisfies DataTableColumnMeta<DaemonWorkspaceCapability>,
},
{
id: 'mcp',
header: t('workspacesOverview.column.mcp'),
cell: ({ row }) => <McpCell workspace={row.original} />,
meta: {
width: 110,
} satisfies DataTableColumnMeta<DaemonWorkspaceCapability>,
},
{
id: 'git',
header: t('workspacesOverview.column.git'),
cell: ({ row }) => <GitCell workspace={row.original} />,
meta: {
width: 150,
} satisfies DataTableColumnMeta<DaemonWorkspaceCapability>,
},
{
id: 'lastActivity',
header: t('workspacesOverview.column.lastActivity'),
cell: ({ row }) => <LastActivityCell workspace={row.original} />,
meta: {
width: 130,
} satisfies DataTableColumnMeta<DaemonWorkspaceCapability>,
},
{
id: 'actions',
header: t('workspacesOverview.column.actions'),
cell: ({ row }) => {
const ws = row.original;
const canRemove =
removalEnabled && !ws.primary && ws.removable === true;
return (
<span className={styles.actionsCell}>
{!ws.trusted && trustGrantEnabled ? (
<Button
size="sm"
variant="outline"
disabled={trustPendingCwd !== null}
onClick={() => handleGrantTrust(ws)}
>
{trustPendingCwd === ws.cwd
? t('workspacesOverview.trusting')
: t('workspacesOverview.trust')}
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={!ws.trusted || creatingCwd !== null}
onClick={() => handleNewSession(ws)}
>
{t('workspacesOverview.newTask')}
</Button>
)}
{canRemove && (
<Button
size="icon"
variant="ghost"
aria-label={t('workspacesOverview.remove')}
title={t('workspacesOverview.remove')}
disabled={removal.submitting}
onClick={() => removal.request(ws)}
>
<Trash2Icon />
</Button>
)}
</span>
);
},
meta: {
width: 170,
headerClassName: 'text-right',
} satisfies DataTableColumnMeta<DaemonWorkspaceCapability>,
},
],
[
creatingCwd,
handleGrantTrust,
handleNewSession,
removal,
removalEnabled,
t,
trustGrantEnabled,
trustPendingCwd,
],
);
const table = useReactTable({
data: workspaces,
columns,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
});
return (
<TooltipProvider>
<div className={styles.page} data-testid="workspaces-overview-panel">
<header className={styles.pageHeader}>
<Button
variant="ghost"
size="icon"
className={styles.backButton}
onClick={onClose}
aria-label={t('workspacesOverview.back')}
>
<ArrowLeftIcon />
</Button>
<h1 ref={initialFocusRef} tabIndex={-1} className={styles.title}>
{t('workspacesOverview.title')}
</h1>
</header>
<div className={styles.pageBody}>
<div className={styles.toolbar}>
<p className={styles.count}>
{t('workspacesOverview.count', { count: workspaces.length })}
</p>
{onAddWorkspace && (
<span className={styles.toolbarActions}>
<Button size="sm" variant="outline" onClick={onAddWorkspace}>
<PlusIcon />
{t('sidebar.addWorkspace')}
</Button>
</span>
)}
</div>
<DataTable table={table} />
</div>
<WorkspaceRemovalDialog
removal={removal}
currentSessionInCandidate={
Boolean(connection.sessionId) &&
connection.workspaceCwd === removal.candidate?.cwd
}
/>
</div>
</TooltipProvider>
);
}