mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-11 17:43:43 -10:00
* 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]>
598 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|