Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .qwen/e2e-tests/2026-08-28-web-shell-workspace-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Web Shell workspace overview (sidebar)

Issue: https://github.com/QwenLM/qwen-code/issues/10399
Design: `docs/design/web-shell/web-shell-workspace-overview.md`

## Baseline

- `main` @ d853f09f: a workspace row shows the folder name, untrusted /
read-only badges and a git chip. The `⋮` menu has one entry, Remove
workspace, and only on removable secondary workspaces.
- The daemon already answers `GET /workspaces/:w/{mcp,skills,extensions,channels,memory,hooks}`
and `PATCH /workspaces/:w` (rename) and `POST /workspaces/:w/reload`.

## Local setup

1. `npm run build` from the repository root so `dist/cli.js` bundles the
Web Shell from this branch.
2. Isolated `QWEN_HOME` with a settings.json that registers one stdio MCP
server that cannot start (for example `command: /nonexistent`) plus one
that can, and at least one project skill under `.qwen/skills`.
3. `node dist/cli.js serve --workspace <primary> --workspace <secondary>`
on loopback; open the served Web Shell.

## Scenarios

### Header counts and chips

Expand the primary workspace with one session running a prompt.

Expected:

- The folder header shows `●1` (success tone) and the total session count; a
session waiting for permission adds a warning-tone count.
- Under the header: the full path, then chips `MCP a/b · Skills n ·
Extensions n · Channels n · Context n`. The MCP chip is warning-toned and
its tooltip names the failed server count.
- Before the ACP child has initialized (fresh daemon, no session yet) the
MCP / Skills chips show `—` with tooltip "not initialized yet", never `0`.
The Context chip is answered by the daemon from disk and settles at its
file count (`0` for a workspace without a QWEN.md) as soon as the first
round lands.

### Request gating

With the Network panel filtered to `/workspaces/`:

- A collapsed workspace issues no facet requests.
- An expanded one issues one request per facet on expand, on window focus and
every 30 s while the tab is visible; nothing while the tab is hidden.

### Workspace menu

Hover a trusted secondary workspace and open `⋮`.

Expected:

- Rename… (only when the daemon advertises `dynamic_workspace_registration`),
Copy path, New task, New worktree task, Reload runtime, Remove workspace.
- Rename opens a dialog prefilled with the current display name; saving
updates the row label without a reload; clearing the name restores the
folder name.
- Copy path puts the absolute path on the clipboard.
- Reload runtime issues `POST /workspaces/<cwd>/reload` and refreshes the
chips.
- New worktree task opens a new draft with the composer's git mode set to
worktree.

On the primary workspace the menu additionally shows a Manage group (MCP
servers, Skills, Extensions, Channels, Settings) with the chip counts; each
opens the corresponding page. Secondary workspaces do not get the group.

### Embedding switch

Mount `<WebShell sidebar={{ workspaceOverview: false }} />`.

Expected: folder headers, path line, chips and Projects count are gone; the
`⋮` menu still offers the non-overview actions.

## Automated coverage

- `packages/web-shell/client/components/sidebar/{workspaceOverviewModel,useWorkspaceOverview,WorkspaceOverview,WorkspaceMenu,WorkspaceRenameDialog}.test.*`
- `packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx`
(menu gating, rename, reload, management targets)
- `packages/web-shell/client/e2e/web-shell.workspace-overview.spec.ts`
(chips, counts, request gating, menu contents against the mock daemon)
135 changes: 135 additions & 0 deletions docs/design/web-shell/web-shell-workspace-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Web Shell workspace overview

Issue: https://github.com/QwenLM/qwen-code/issues/10399

## Goal

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.

## Behavior

### Folder header

- The header keeps its name, badges and git chip. It gains session counts at
its right edge: sessions waiting on the user (warning tone), sessions with a
prompt in flight (success tone), and the total. A total from a truncated
catalog page shows as `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.
- The name carries the full path as a tooltip. While the section is expanded
the path is printed under the header.
- The Projects label shows the number of registered workspaces once there is
more than one.

### Facet chips

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.

- MCP, skills and hooks are discovered by the workspace's ACP child. Until it
reports `initialized`, the chip shows `—` and the tooltip says the runtime
is not initialized yet. A placeholder is never rendered as `0`.
- Context files are read from disk by the daemon itself, so its answer is
always definitive: a workspace without a QWEN.md shows `0`.
- The MCP chip takes the warning tone when a server errored or discovery
finished with an enabled server still not connected; the channels chip when
an instance is in the error state.
- Below the sidebar's tight width the chips drop their text labels.
- Extensions, channels and context files are daemon-side facets. When they
are unknown the daemon lacks the route or the fetch failed, so their tooltip
says "unavailable on this daemon" rather than "not initialized yet". The
chip row itself appears only once the first fetch round has landed, so a
round still in flight never reads as a missing route.
- Chips are read-only. Opening a management page is a menu action, so the
chips never take the button role and their accessible names cannot collide
with the navigation buttons that share the same words.

### Workspace menu

The hover `⋮` on a workspace row replaces the single-item removal menu:

- Rename… (dynamic registration daemons, on registration-backed rows only —
the daemon's bound workspace has no registration to persist a name in;
opens a dialog; an empty name falls back to the folder name and control
characters are refused before the request is sent), Copy path, New task,
New worktree task (only when
the git poll reports a branch — a worktree needs a repository, and the
composer would otherwise have no chip to show or undo the armed intent).
- Manage: MCP servers, Skills, Extensions, Channels, Settings, with the chip
counts next to the first four (part of the item's accessible name). The
menu keeps the row's last snapshot while the row is collapsed, so the
counts do not vanish on collapse; the action area stays visible while its
menu is open so Escape returns focus to the trigger.
- Reload runtime (`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.

## Data flow

`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.

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.

## Embedding

- `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 a new session armed with worktree intent.
The arm is dropped if another session start ran while the creation was in
flight, and an armed intent survives a transient git-status gap — only a
session, an untrusted workspace, a no-branch answer or a draft workspace
switch clears it.

## Follow-ups (layers B and C in the issue)

- A Trust… menu entry. `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.
- Bind the management pages to a chosen workspace so every row can open its
own MCP / Skills / Extensions view.
- A Workspaces overview page with a table across workspaces.
- `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.
107 changes: 107 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,9 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
onOpenSplitView?: () => void;
onMobileClose?: () => void;
onNewSession?: () => Promise<boolean> | boolean;
onNewWorktreeSession?: (
workspaceCwd?: string,
) => Promise<boolean> | boolean | void;
onLoadSession?: (sessionId: string) => Promise<void> | void;
onSelectCurrentSession?: () => void;
onSessionsDeleted?: (sessionIds: string[]) => void;
Expand Down Expand Up @@ -1128,6 +1131,15 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
},
'new session',
),
React.createElement(
'button',
{
'data-testid': 'new-worktree-session',
type: 'button',
onClick: () => void props.onNewWorktreeSession?.(),
},
'new worktree session',
),
React.createElement(
'button',
{
Expand Down Expand Up @@ -11072,6 +11084,101 @@ describe('App session callbacks', () => {
);
});

it('keeps an armed worktree intent across a transient git-status failure', async () => {
mockConnection.sessionId = undefined;
mockWorkspace.capabilities = {
workspaces: [
{ id: 'primary', cwd: '/workspace', primary: true, trusted: true },
],
};
const workspaceGit = vi.fn().mockResolvedValue({ branch: 'main' });
mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({
workspaceGit,
workspaceSkills: mockWorkspaceActions.loadSkillsStatus,
}));
renderApp();
await flush();
await flush();

const intentChange = testState.latestChatEditorProps?.onGitModeIntentChange;
act(() => {
intentChange?.({ mode: 'worktree', slug: 'feat-a' });
});
await flush();
expect(testState.latestChatEditorProps?.gitModeIntent).toEqual({
mode: 'worktree',
slug: 'feat-a',
});

// One refetch round fails (daemon busy, network blip): the status is
// transiently unknown, but the requested worktree must not silently
// degrade to a plain session once the status recovers.
workspaceGit.mockRejectedValueOnce(new Error('daemon busy'));
await act(async () => {
window.dispatchEvent(new Event('focus'));
await Promise.resolve();
await Promise.resolve();
});
await flush();
await act(async () => {
window.dispatchEvent(new Event('focus'));
await Promise.resolve();
await Promise.resolve();
});
await flush();
await flush();
expect(testState.latestChatEditorProps?.gitModeIntent).toEqual({
mode: 'worktree',
slug: 'feat-a',
});
});

it('arms the worktree intent from the sidebar entry, unless another session start intervened', async () => {
mockConnection.sessionId = undefined;
mockWorkspace.capabilities = {
workspaces: [
{ id: 'primary', cwd: '/workspace', primary: true, trusted: true },
],
};
mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({
workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }),
workspaceSkills: mockWorkspaceActions.loadSkillsStatus,
}));
const { container } = renderApp();
await flush();
await flush();
const worktreeButton = container.querySelector<HTMLButtonElement>(
'[data-testid="new-worktree-session"]',
)!;
const newSessionButton = container.querySelector<HTMLButtonElement>(
'[data-testid="new-session"]',
)!;

// Alone: the draft is armed for a worktree.
await act(async () => {
worktreeButton.click();
await Promise.resolve();
});
await flush();
await flush();
expect(testState.latestChatEditorProps?.gitModeIntent).toEqual({
mode: 'worktree',
});

// A plain new task started while the worktree creation is still in
// flight owns the draft; the late arm must not land on it.
await act(async () => {
worktreeButton.click();
newSessionButton.click();
await Promise.resolve();
});
await flush();
await flush();
expect(testState.latestChatEditorProps?.gitModeIntent).toEqual({
mode: 'current',
});
});

it('reloads skills from the target workspace when starting a new session', async () => {
const { container } = renderApp({
lockedWorkspaceCwd: '/work/secondary',
Expand Down
Loading
Loading