docs/design/web-shell/web-shell-workspace-overview.md
Issue: https://github.com/QwenLM/qwen-code/issues/10399
Make a workspace a first-class object in the Web Shell sidebar: show what it contains and whether it is healthy without opening a session, and put every workspace-level action behind its own menu. This is layer A of the plan in the issue — a frontend-only change on top of daemon routes that already exist.
N+. Collapsing a row disables its catalog query, so
the row keeps the last counts it computed: they stay visible while collapsed
and refresh on the next expand. While the query is active and a page is
missing (a session-source switch), no counts are shown rather than stale
ones above an empty list.While a trusted workspace is expanded, a chip row summarizes MCP servers
(connected/enabled), skills (enabled), extensions (active, or
active/total when they differ), channels (connected/configured) and
context files (count). Hooks are available but off by default.
initialized, the chip shows — and the tooltip says the runtime
is not initialized yet. A placeholder is never rendered as 0.0.The hover ⋮ on a workspace row replaces the single-item removal menu:
POST /workspaces/:w/reload), then Remove workspace.Each entry appears only when the workspace's state allows it: untrusted rows that cannot be removed still show nothing, locked-workspace renderers still suppress the whole action area.
The Manage group is offered on the daemon's primary workspace only. The management pages read the connection's bound workspace, so a secondary row cannot open its own view yet; that is layer B1 of the issue.
useWorkspaceOverview(client, cwd, { enabled, items }) fans out over
client.workspaceByCwd(cwd) to /mcp, /skills, /extensions,
/channels, /memory and /hooks, one request per requested facet. Each
call fails independently — an older daemon without a route, a transient
error, or a malformed body — leaves that facet undefined and keeps the
others. A facet keeps its last known value across up to three consecutive
unanswered rounds, then reads as unavailable, so a route that stays gone
after a rollback cannot freeze a stale count on the chip. Rounds that time
out after the next tick has already replaced them still count: the SDK's
request deadline equals the poll cadence, so during a daemon hang every
round is superseded before it lands, and only their observed misses can
expire the facet. The budget is scoped to one bookkeeping session: a reset
boundary (a cwd change, the section collapsing) advances an epoch, and a
round launched before the boundary can neither book misses into the fresh
session nor refill it with a stale success.
Fetching is gated on the section being expanded, the workspace trusted and the default header rendered (a locked sidebar's custom header has no chip or menu to feed), and polls every 30 s only while the document is visible, plus a refetch on window focus and on the sidebar's reload token. Collapsed rows cost nothing, and a synthetic fallback workspace without a real cwd is never asked.
Measured against the mock daemon (npm run dev, React StrictMode, 5 trusted
workspaces all expanded, tab visible): after the initial round the sidebar
issued 25 facet requests per 30 s tick — 50 over 60 s — next to the 33
session-catalog and git-status requests the same rows already made in that
window. Without StrictMode the initial round is 25 requests, not 50. That is
the cost the layer-C overview endpoint collapses to 5 per tick.
Session counts come from the catalog page the row already lists; the primary workspace, whose sessions the sidebar lists itself, gets its counts passed in.
sidebar.workspaceOverview: false keeps the plain folder headers;
{ items: [...] } selects the chips.onOpenWorkspaceManagement(target, workspaceCwd) and
onNewWorktreeSession(workspaceCwd) are new sidebar callbacks; the app
wires them to openPanel and to createNewSession with a worktree git
intent. The intent is set in the same synchronous step that clears the
previous one, so it belongs to that draft from the start: a prompt
submitted while the clear is still in flight gets the worktree, and any
later session start or draft workspace switch resets it like any other
intent. An armed intent survives a transient git-status gap — only a
session, an untrusted workspace, a no-branch answer for the draft's own
workspace (the status is keyed by workspaceCwd, so the answer of a
workspace being left never clears an intent armed for the new one) or a
draft workspace switch clears it; re-selecting the draft's own workspace
from the composer picker is a no-op, and an intent set while a session
already exists is cleared immediately.POST /workspaces/:w/trust/request only records a
request that needs operator action and a daemon restart
(accepted: false, requiresOperatorAction: true), so an entry today would
promise a change it cannot make; it needs an "operator action required"
feedback surface first.GET /workspaces/:w/overview on the daemon to collapse the fan-out into one
request, advertised as workspace_overview, once the workspace-runtime
stack has landed.WorkspacesOverviewPanel (App panel id 'workspaces') is a full-page table
of every registered workspace, styled after the Session Overview panel:
name with primary/untrusted badges, path, active session counts (running /
needs-attention, 30 s cadence), MCP health (connected/configured, unknown
while the runtime is not initialized), branch plus dirty count (60 s, the
sidebar chip's discipline) and last activity. Daemon-owned kind: 'live'
runtimes are not rows. Per-row actions: New task (targets that workspace)
and Remove where the sidebar row would offer it. Entries: a "Manage
workspaces…" row at the end of the Projects section (hidden when the
sidebar is locked to one workspace) and an opt-in workspacesOverview
footer item outside the default set.
The removal flow moved out of the sidebar into
workspaces/useWorkspaceRemoval + WorkspaceRemovalDialog, shared by the
sidebar row and the panel: confirm → remove, workspace_busy surfaces the
daemon's activity report and arms a forced retry (still blocked for the
workspace the active session lives in), workspace_mismatch reconciles,
and in-progress answers retry briefly. The sidebar keeps its own
reconciliation (catalog invalidation, selection, capability refresh) as the
hook's onRemoved callback; the panel invalidates the catalog and
refreshes capabilities.