Repository navigation
🤖 feat: keep the VS Code host's workspace list live - #5651
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b21cc4494f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 40d359fe64
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Readiness record
Generated with |
## Summary Last layer of the coder#5109 stack, after coder#5651 (merged: live host workspace list). The VS Code webview dock now shows the desktop sub-agent tasks strip above the background processes strip. It reuses the shared `SubAgentTasksContent` (coder#5634). A new `SubAgentTasksDock` feeds it from the host's live workspace list and a per-workspace activity map. Clicking a row asks the host to select that sub-agent. `chatUiCapabilities.subAgentTasks` is now `supported`. This layer also moves the host's activity pump from "selected workspace, tied to the chat stream" to "selected workspace and its descendants, with its own controller". The activity set is recomputed only when membership changes. Refs coder#5109. Nested-run discovery is deferred to coder#5652; see Scope. I will close coder#5109 by hand after this PR merges, with a residual comment. ## Implementation Host (`extension.ts`, `workspaceActivityPump.ts`): - The activity pump runs for `[selected, ...descendants]` and posts a per-workspace map `{ activeBashMonitorCount, streaming, activeWorkflowRunIds }`. Unchanged maps are not posted. Goal-only (`transientGoalOnly`) events are skipped because they carry stale baseline fields. - The selected + descendants set is computed in one pass over the list (a parent → children map). The host recomputes it only on a selection change, the snapshot, or a metadata update that changes a workspace's parent link (task creation or removal included). It restarts the pump (one bulk `activity.list`) only when the set differs. Activity events never recompute it: the pump's tracked set is fixed when it starts. - The pump has its own controller, so it no longer stops with the chat stream. It stops on deselection, file mode, refresh failure or dispose. `isSelected` also ignores callbacks from a replaced pump. - The host's `UiWorkspace` gains a narrow `task` slice, for sub-agent workspaces only: `title`, `taskStatus`, `taskExecutionStatus`, `workflowTask`, `reportedAt`, `archivedAt`. No paths or prompts. The per-workspace projection compare from coder#5651 includes it, so a task status change re-posts the list and an unrelated event still posts nothing. Webview and shared code: - The webview takes the selected workspace's monitor count from the activity map, so the waiting-on-monitor barrier works as before. - The tray helpers take a `SubAgentTaskWorkspace` (`Pick<FrontendWorkspaceMetadata, …>`) instead of the full metadata. `isWorkspaceDelegatedActivityActive` takes the four fields it reads. Desktop callers pass full metadata unchanged, and the webview builds the Pick from `UiWorkspace` without fabricating fields. - `SubAgentTasksDock` runs the same `collectDescendantAgents` and `mergeActiveWorkflowGroups` as desktop. Its per-descendant hints come from activity: monitors, plus a "working" hint = streaming, active workflow runs or armed monitors. The expanded state uses the desktop's persisted key. - No new IPC message type, no new persisted field, no new bridge procedure, no manual memo. ## Scope: nested-run discovery deferred to coder#5652 Desktop also calls `workflows.listActiveRuns` (cold-mount discovery) and polls `workflows.getRunStatuses` for nested (workflow-in-workflow) runs between workers. Those procedures are not bridged. Without them, a nested run's header leaves the strip once its workers finish. Its enclosing top-level run stays visible from activity as "Workflow · running" (tested), so the strip never reads as empty or "all finished" while a workflow runs. coder#5652 tracks bridging both procedures. ## Activity set measurement (perf-owner requirement 3) In-process harness (the same one as coder#5651: real `XumChatViewProvider`, local oRPC server, 4,502 workspaces), with `ws-1` selected and one sub-agent task under it. A timing-free counter patch counts calls of the set computation. Measured before the two coder#5651 review fixes were rebased in; they do not touch the activity path. | Measure | Result | | --- | --- | | Metadata events in 60 s (10 per second) | 592: 533 on unrelated workspaces, 59 renames of the selected task | | Selected + descendants recomputes during them | **0** | | Activity pump restarts during them | 0 | | `workspaces` posts during them | 59 (one per task rename, 0 for the unrelated events) | | A new task appears under the selection | 1 recompute, 1 restart, 27.83 ms to the new activity post, which tracks `ws-1`, `ws-child`, `ws-child-2` | <details> <summary>Harness (kept outside the repo)</summary> Built with the `make-bench.sh` from coder#5651, then run from `vscode/` as `bun test src/bench5109.test.ts -t bench5109b`. Counter patch applied to `extension.ts` for the run: ```diff diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index d5a74af34e..191328dd56 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -160,6 +160,7 @@ function toUiWorkspace(workspace: WorkspaceWithContext): UiWorkspace { /** The workspace and its descendants, parents before children; one pass over the list. */ function selfAndDescendantIds(workspaces: readonly WorkspaceWithContext[], rootId: string): string[] { + (globalThis as any).__recomputes = ((globalThis as any).__recomputes ?? 0) + 1; const children = new Map<string, string[]>(); for (const { id, parentWorkspaceId } of workspaces) { if (parentWorkspaceId == null) continue; ``` Benchmark appended to the copy of `extension.test.ts`: ```ts // ---- coder#5109 PR2b benchmark (scratch, not committed): activity set recomputes under load ---- describe("bench5109b", () => { test("bench5109b: 10 unrelated metadata events/s for 60 s, selected workspace with a task", async () => { const SECONDS = Number(process.env.BENCH_SECONDS ?? 60); const synthetic = Array.from({ length: 4_500 }, (_, i) => ({ ...WORKSPACE, id: `ws-s${i}`, name: `s${i}`, createdAt: "2026-09-01T00:00:00.000Z", })); const child = { ...WORKSPACE, id: "ws-child", name: "child", parentWorkspaceId: WORKSPACE.id }; const harness = await setup([WORKSPACE, child, ...synthetic]); const { state } = harness.server; type Msg = PostedMessage & { workspaces?: unknown[]; activity?: Record<string, unknown> }; const posted = harness.posted as Msg[]; const count = (type: string) => posted.filter((m) => m.type === type).length; harness.send({ type: "selectWorkspace", workspaceId: WORKSPACE.id }); await until(() => count("workspaceActivity") >= 1, "the first activity post"); await new Promise((r) => setTimeout(r, 1000)); const lists0 = state.activityLists; const recomputes0 = (globalThis as any).__recomputes ?? 0; const posts0 = count("workspaces"); let sent = 0; const emitter = setInterval(() => { // Unrelated to the activity set: synthetic workspaces, plus a rename of the selected task. const target = sent % 10 === 9 ? { ...child, title: `T${sent}` } : synthetic[(sent * 37) % synthetic.length]; state.metadata.push({ workspaceId: target.id, metadata: { ...target, pinned: sent % 2 === 0 } }); sent += 1; notify(); }, 100); await new Promise((r) => setTimeout(r, SECONDS * 1000)); clearInterval(emitter); await new Promise((r) => setTimeout(r, 500)); const restartsUnderLoad = state.activityLists - lists0; const recomputesUnderLoad = ((globalThis as any).__recomputes ?? 0) - recomputes0; const listPosts = count("workspaces") - posts0; // A membership change: a second task appears under the selection. const t0 = performance.now(); const activityPosts = count("workspaceActivity"); state.metadata.push({ workspaceId: "ws-child-2", metadata: { ...child, id: "ws-child-2", name: "child-2" } }); notify(); await until(() => count("workspaceActivity") > activityPosts, "the restarted pump's post"); const restartMs = performance.now() - t0; const last = posted.filter((m) => m.type === "workspaceActivity").at(-1); console.log( [ "BENCH5109B", `workspaces listed: ${synthetic.length + 2}; window: ${SECONDS} s; selected ws-1 with 1 task`, `load: ${sent} metadata events (${Math.floor(sent / 10)} task renames, the rest unrelated); selected + descendants recomputes ${recomputesUnderLoad}; activity pump restarts ${restartsUnderLoad}; workspaces posts ${listPosts}`, `membership change (new task): recomputes ${((globalThis as any).__recomputes ?? 0) - recomputes0 - recomputesUnderLoad}; restarts ${state.activityLists - lists0 - restartsUnderLoad}; latency to the new activity post ${restartMs.toFixed(2)} ms; tracked ${Object.keys(last?.activity ?? {}).join(",")}`, ].join("\n") ); }, 10 * 60_000); }); ``` </details> ## Tests - Host lifecycle (`extension.test.ts`): - Switching workspaces: selecting none and then a workspace starts a new pump (a new `activity.list`). - Removing a descendant restarts the pump for the smaller set, and the next post no longer contains the child. An unrelated update and a rename of the child (a list re-post) keep the running pump. - Late events from a replaced pump: the first pump's `activity.list` is held while removing the descendant replaces the pump. When the held read answers, it posts nothing; only the replacement's map is posted. With the abort and the `isSelected` controller check both removed, this case fails (12 extra lines received). - `workspaceActivityPump.test.ts`: descendants are tracked; each workspace's monitors, stream flag and workflow runs are posted; goal-only events and other workspaces are ignored; null resets a workspace. - Webview (`vscode webview sub-agent tasks strip (coder#5109)`): - The strip appears when the host's list gains a child: "1 sub-agent · 1 active". - A row click sends `selectWorkspace`. - A reported child reads "inactive" until activity reports an armed monitor; then it shows "1 active" and "Monitoring". - An archived child leaves the strip. - A worker-less active run shows "running". - Pre-fix: with the webview `App.tsx` from coder#5651, both strip tests fail (2 fail): the strip is absent. - Drift guard `bun test ./vscode/src/webview/webviewCss.test.ts` passes. No CSS files changed. - `make check-react-compiler`: 23/24, with only the existing ProjectSidebarInner skip. Webview at 300 px (narrow VS Code sidebar). It shows nested sub-agents, a Monitoring child and a workflow group:  390 px:  900 px (same rows and labels as the desktop tray):  ## Risks Low to medium. Desktop only sees type narrowing, and the existing tray tests pass unchanged. Activity no longer stops when the chat stream ends; it stops on deselection, file mode, refresh failure or dispose. Size: 559 changed lines (477+, 82-), tests included. The coordinator accepted up to 560. The reason: the activity pump moved here from coder#5651 to keep that layer small, and this layer must also delete main's chat-tied pump (23 lines of deletions). --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$48.29`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=48.29 -->
Summary
Second layer of the #5109 stack (the first, #5634, has merged). The VS Code extension host now keeps its workspace list live between refreshes. One
workspace.onMetadatasubscription feeds the host. For each update, the host compares only the changed workspace's projection with what it last posted. Events that change nothing the webview receives post nothing and touch no list. The webview UI and the dock activity are unchanged in this layer. The next layer (PR2b) adds the selected + descendants activity pump and the sub-agent tasks strip.Refs #5109
Implementation
vscode/src/workspaceMetadataPump.ts(new, unit-tested outsideextension.ts): it forwards the snapshot once (onSnapshot) and then oneonUpdate(workspaceId, metadata | null)per event. Archived workspaces arrive as removals, asworkspace.listomits them. An aborted subscription ignores late events and errors.extension.ts:refreshWorkspacesstarts one metadata subscription on the client that just listed the workspaces, so it needs no extra discovery or auth ping. It aborts the previous one. Amux.serverUrlor auth token change, file mode, refresh failures anddisposestop it, so no event from an old endpoint lands.postedProjections). An update computestoUiWorkspacefor that one workspace and compares the string. Equal means no list rebuild, no sort and no post: the host only refreshes its stored copy of that workspace in a map (for example thenamethat opening the workspace reads). Removal of an unknown workspace also returns at once.onChatbatchReplay or replayWindow opt-in.Numbers (perf-owner scenario: ~4.5k workspaces, 10 unrelated metadata events per second for 60 s)
Two runs, both with the real
XumChatViewProviderfromextension.ts. "Unrelated" means a change to fields the webview is never sent. The harness runs under Bun. The handler time comes from a timing patch applied only for these runs (script below).In-process (local oRPC server in the test process, 4,502 workspaces):
workspacesposts from themRerun after the round-1 review fix (head
40d359fe64, which adds oneMap.setto the skip path): 594 unrelated events, 0workspacesposts, skip-path handler time 13.10 ms in total (0.02 ms each), process CPU 8,990 ms under load vs 1,511 ms idle, event-loop delay p99 2.40 ms under load vs 1.60 ms idle. 10 real task renames gave 10 posts, latency p50 14.23 ms, max 15.43 ms. The table above and the live table below were measured atb21cc4494f; the two later review fixes touch only the skip path'sMap.setand the subscription's shutdown on connection changes.Live
xum server(mock AI, a fresh root seeded with 4,502 workspaces, one of them a sub-agent task). The load driver alternatessetPinnedandupdateTagson synthetic workspaces at 10 calls per second. The real change isupdateTitleon the sub-agent task.workspacesposts from themThe process CPU includes the oRPC transport and, in the live run, the load driver's 593 HTTP calls in the same process. The host's own share is the handler time row.
Earlier small live run (fresh root, 7 workspaces): 31 metadata events, of which 20 changed no projected field (pin and tag updates, unchanged titles, the second event of each creation or archive). Those 20 caused 0 posts. The other 11 caused 11 posts.
Benchmark harness (kept outside the repo)
make-bench.shcopiesvscode/src/extension.test.ts(mockedvscode, local oRPC server, real chat view provider), turns off Bun.serve's 10 s idle timeout, optionally points the provider at a live server, and appends one benchmark. Run fromvscode/:bun test src/bench5109.test.ts -t bench5109(in-process) orLIVE_URL=... LIVE_TOKEN=... bun test src/bench5109.test.ts -t live5109.Timing patch applied to
extension.tsfor the handler rows:In-process benchmark:
Live benchmark (the server ran as
XUM_ROOT=<seeded root> XUM_MOCK_AI=1 node dist/cli/index.js server --auth-token ..., with 4,500s<i>workspaces plus a parent and one sub-agent task inconfig.json):Tests
extension.test.ts: a burst of 21 metadata events against 4,500 synthetic workspaces plus the real ones. Only the creation, the rename, the archive and a final creation post. Five unrelated updates to synthetic workspaces, five to the new child, a removal of an unknown workspace and a removal of an already archived one post nothing. Each post still carries all 4,500 synthetic workspaces.extension.test.ts: with a display title set, a rename changes onlyname; opening the workspace afterwards uses the new checkout path.extension.test.ts: a server URL change and an auth token change each close the subscription; a refresh opens a new one.workspaceMetadataPump.test.ts: a subscription replaced mid-stream ignores its late event and its error.Pre-fix: with
extension.tsreverted to the base commit, the new burst case inextension.test.tsfails (timed out waiting for the end of the burst); the other 8 cases in the two files pass.Risks
Low. The host now posts
workspacesbetween refreshes. The webview already handles that message idempotently: it re-seeds sub-agent AI settings and does not reset the transcript.Size: 313 changed lines (304+, 9-), tests included.
Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:high• Cost:$39.94