Repository navigation
🤖 refactor: extract props-driven SubAgentTasksContent from the sub-agent tasks tray - #5634
Merged
Merged
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. |
Member
Author
|
Readiness record for head
Generated with |
This was referenced Oct 4, 2026
yermakoffivan
pushed a commit
to yermakoffivan/mux
that referenced
this pull request
Oct 5, 2026
## Summary Second layer of the coder#5109 stack (the first, coder#5634, has merged). The VS Code extension host now keeps its workspace list live between refreshes. One `workspace.onMetadata` subscription 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 coder#5109 ## Implementation - `vscode/src/workspaceMetadataPump.ts` (new, unit-tested outside `extension.ts`): it forwards the snapshot once (`onSnapshot`) and then one `onUpdate(workspaceId, metadata | null)` per event. Archived workspaces arrive as removals, as `workspace.list` omits them. An aborted subscription ignores late events and errors. - `extension.ts`: - `refreshWorkspaces` starts 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. A `mux.serverUrl` or auth token change, file mode, refresh failures and `dispose` stop it, so no event from an old endpoint lands. - The host keeps a per-workspace map of the JSON it last posted (`postedProjections`). An update computes `toUiWorkspace` for 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 the `name` that opening the workspace reads). Removal of an unknown workspace also returns at once. - A real change (creation, removal, archive, or a projected field such as the title) re-sorts the list once, built from that map, and posts it. The snapshot takes the same path, and posts only if the whole list differs. A refresh always posts, because a new webview has no list. - If the selected workspace disappears, the host clears the selection, as a refresh does. - There is no timer debounce and no `onChat` batchReplay 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 `XumChatViewProvider` from `extension.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): | | This PR | Previous design of this PR (full list rebuild per event) | | --- | --- | --- | | Unrelated events | 593 | 593 | | `workspaces` posts from them | **0** | 0 | | Host handler time for them | 11.72 ms in total (0.02 ms each) | 3,395.6 ms for 604 calls (5.6 ms each) | | Process CPU during the 60 s load (idle 60 s: 1,356 ms / 1,375 ms) | 7,908 ms | 14,242 ms | | Event-loop delay under load | p50 1.00 ms, p99 3.26 ms | p50 1.00 ms, p99 5.94 ms | | 10 real task renames | 10 posts, latency p50 6.25 ms, max 13.24 ms | 10 posts, p50 10.15 ms, max 12.02 ms | Rerun after the round-1 review fix (head `40d359fe64`, which adds one `Map.set` to the skip path): 594 unrelated events, **0** `workspaces` posts, 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 at `b21cc4494f`; the two later review fixes touch only the skip path's `Map.set` and 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 alternates `setPinned` and `updateTags` on synthetic workspaces at 10 calls per second. The real change is `updateTitle` on the sub-agent task. | Measure | Result | | --- | --- | | Unrelated API changes / metadata events delivered | 593 / 445 | | `workspaces` posts from them | **0** | | Host handler time for the 445 events | 11.03 ms in total (0.025 ms each) | | Process CPU, 60 s load vs 60 s idle | 8,279 ms vs 1,050 ms | | Event-loop delay, load vs idle | p99 2.49 ms vs 1.88 ms (max 6.70 ms vs 4.38 ms) | | 10 sub-agent retitles | 10 posts; latency from the API call to the post p50 131.78 ms, max 232.72 ms | The 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. <details> <summary>Benchmark harness (kept outside the repo)</summary> `make-bench.sh` copies `vscode/src/extension.test.ts` (mocked `vscode`, 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 from `vscode/`: `bun test src/bench5109.test.ts -t bench5109` (in-process) or `LIVE_URL=... LIVE_TOKEN=... bun test src/bench5109.test.ts -t live5109`. ```sh # usage: bash make-bench.sh.txt <worktree> <append-file> # Copies extension.test.ts (its harness: mocked vscode, local oRPC server, real chat view # provider), disables the local server's 10 s idle timeout, and appends the benchmark. set -euo pipefail W=$1; A=$2 sed -e 's/^ port: 0,$/ port: 0,\n idleTimeout: 0,/' \ -e 's#settings.set("mux.serverUrl", server.url);#settings.set("mux.serverUrl", process.env.LIVE_URL ?? server.url);#' \ -e 's#\[\[SECRET_KEY, "token-a"\]\]#[[SECRET_KEY, process.env.LIVE_TOKEN ?? "token-a"]]#' \ "$W/vscode/src/extension.test.ts" > "$W/vscode/src/bench5109.test.ts" cat "$A" >> "$W/vscode/src/bench5109.test.ts" grep -c 'idleTimeout: 0\|LIVE_URL ?? server.url\|LIVE_TOKEN ?? "token-a"' "$W/vscode/src/bench5109.test.ts" ``` Timing patch applied to `extension.ts` for the handler rows: ```diff diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 31eba1b..e8722738ab 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1781,13 +1781,14 @@ class XumChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposab signal: controller.signal, onSnapshot: (workspaces) => this.relist(workspaces), onUpdate: async (workspaceId, metadata) => { + const __t0 = performance.now(); (globalThis as any).__benchN = ((globalThis as any).__benchN ?? 0) + 1; queueMicrotask(() => { (globalThis as any).__benchMs = ((globalThis as any).__benchMs ?? 0) + performance.now() - __t0; }); const previous = this.workspacesById.get(workspaceId); const next = metadata && { ...metadata, extensionMetadata: previous?.extensionMetadata }; // Compare only this workspace with its last posted projection: most events change fields // the webview never sees, and rebuilding a list of thousands per event is too slow. Such // an event also leaves the stored copy as is; the host reads nothing it changed. const posted = this.postedProjections.get(workspaceId); - if (posted === (next ? JSON.stringify(toUiWorkspace(next)) : undefined)) return; + if (posted === (next ? JSON.stringify(toUiWorkspace(next)) : undefined)) { (globalThis as any).__skipMs = ((globalThis as any).__skipMs ?? 0) + performance.now() - __t0; (globalThis as any).__skipN = ((globalThis as any).__skipN ?? 0) + 1; return; } const others = this.workspaces.filter((w) => w.id !== workspaceId); await this.relist(next ? [...others, next] : others); }, ``` In-process benchmark: ```ts // ---- coder#5109 PR2a benchmark (scratch, not committed): appended to a copy of extension.test.ts ---- describe("bench5109", () => { test("bench5109: 10 unrelated metadata events/s for 60 s with 4.5k workspaces", 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[] }; const lists = () => (harness.posted as Msg[]).filter((m) => m.type === "workspaces").length; await until(() => lists() >= 1, "the first list"); await new Promise((r) => setTimeout(r, 1000)); // let the snapshot settle // Event-loop delay: a 10 ms sampler; delay = actual interval - 10 ms. const sampleLoop = (ms: number) => new Promise<number[]>((resolve) => { const delays: number[] = []; let last = performance.now(); const timer = setInterval(() => { const now = performance.now(); delays.push(Math.max(0, now - last - 10)); last = now; }, 10); setTimeout(() => { clearInterval(timer); resolve(delays); }, ms); }); const stats = (values: number[]) => { const s = [...values].sort((a, b) => a - b); const at = (p: number) => s[Math.min(s.length - 1, Math.floor(p * s.length))].toFixed(2); return `p50 ${at(0.5)} ms, p99 ${at(0.99)} ms, max ${s[s.length - 1].toFixed(2)} ms`; }; const cpuMs = (u: NodeJS.CpuUsage) => ((u.user + u.system) / 1000).toFixed(0); // Baseline: same window, no events. let cpu0 = process.cpuUsage(); const idleDelays = await sampleLoop(SECONDS * 1000); const idleCpu = process.cpuUsage(cpu0); // Load: 10 unrelated events per second (fields the webview is never sent). const posts0 = lists(); cpu0 = process.cpuUsage(); let sent = 0; const emitter = setInterval(() => { const target = synthetic[(sent * 37) % synthetic.length]; state.metadata.push({ workspaceId: target.id, metadata: { ...target, namedWorkspacePath: `/tmp/n${sent}`, pinned: sent % 2 === 0 }, }); sent += 1; notify(); }, 100); const loadDelays = await sampleLoop(SECONDS * 1000); clearInterval(emitter); await new Promise((r) => setTimeout(r, 500)); const loadCpu = process.cpuUsage(cpu0); const unrelatedPosts = lists() - posts0; // Real task changes: rename the sub-agent 10 times; each must post once. const latencies: number[] = []; const posts1 = lists(); for (let i = 0; i < 10; i++) { const before = lists(); const t0 = performance.now(); state.metadata.push({ workspaceId: child.id, metadata: { ...child, title: `Task ${i}` } }); notify(); await until(() => lists() > before, "the rename post"); latencies.push(performance.now() - t0); await new Promise((r) => setTimeout(r, 200)); } const realPosts = lists() - posts1; const report = [ `workspaces listed: ${synthetic.length + 2}; window: ${SECONDS} s`, `idle: CPU ${cpuMs(idleCpu)} ms; event-loop delay ${stats(idleDelays)}`, `load: ${sent} unrelated events; workspaces posts ${unrelatedPosts}; CPU ${cpuMs(loadCpu)} ms; event-loop delay ${stats(loadDelays)}`, `real: 10 task renames; workspaces posts ${realPosts}; latency ${stats(latencies)}`, ].join("\n"); console.log(`BENCH5109\n${report}\nhandler: ${(globalThis as any).__benchN} calls, ${(globalThis as any).__benchMs?.toFixed(1)} ms; skip path ${(globalThis as any).__skipN} calls ${(globalThis as any).__skipMs?.toFixed(2)} ms`); }, 10 * 60_000); }); ``` Live benchmark (the server ran as `XUM_ROOT=<seeded root> XUM_MOCK_AI=1 node dist/cli/index.js server --auth-token ...`, with 4,500 `s<i>` workspaces plus a parent and one sub-agent task in `config.json`): ```ts // ---- coder#5109 PR2a live benchmark (scratch, not committed): real xum server with ~4.5k workspaces ---- import { createApiClient as createLiveClient } from "./api/client"; describe("live5109", () => { test("live5109: 10 unrelated metadata changes/s for 60 s against a live server", async () => { const SECONDS = Number(process.env.BENCH_SECONDS ?? 60); const live = createLiveClient({ baseUrl: process.env.LIVE_URL!, authToken: process.env.LIVE_TOKEN! }); const all = await live.workspace.list(); const child = all.find((w) => w.parentWorkspaceId != null); const synthetic = all.filter((w) => w.name.startsWith("s")); if (!child || synthetic.length < 4000) throw new Error(`bad root: ${all.length}`); const harness = await setup(); type Msg = PostedMessage & { workspaces?: unknown[] }; const lists = () => (harness.posted as Msg[]).filter((m) => m.type === "workspaces"); await until(() => lists().length >= 1, "the first list"); await new Promise((r) => setTimeout(r, 3000)); // the snapshot after the refresh const listedCount = lists().at(-1)?.workspaces?.length; const sampleLoop = (ms: number) => new Promise<number[]>((resolve) => { const delays: number[] = []; let last = performance.now(); const timer = setInterval(() => { const now = performance.now(); delays.push(Math.max(0, now - last - 10)); last = now; }, 10); setTimeout(() => { clearInterval(timer); resolve(delays); }, ms); }); const stats = (values: number[]) => { const s = [...values].sort((a, b) => a - b); const at = (p: number) => s[Math.min(s.length - 1, Math.floor(p * s.length))].toFixed(2); return `p50 ${at(0.5)} ms, p99 ${at(0.99)} ms, max ${s[s.length - 1].toFixed(2)} ms`; }; const cpuMs = (u: NodeJS.CpuUsage) => ((u.user + u.system) / 1000).toFixed(0); let cpu0 = process.cpuUsage(); const idleDelays = await sampleLoop(SECONDS * 1000); const idleCpu = process.cpuUsage(cpu0); // Unrelated: pin/unpin and tag edits, which the webview is never sent. const posts0 = lists().length; cpu0 = process.cpuUsage(); let sent = 0; const calls: Promise<unknown>[] = []; const emitter = setInterval(() => { const target = synthetic[(sent * 37) % synthetic.length]; calls.push( sent % 2 === 0 ? live.workspace.setPinned({ workspaceId: target.id, pinned: (sent >> 1) % 2 === 0 } as never) : live.workspace.updateTags({ workspaceId: target.id, tags: { bench: String(sent) } } as never) ); sent += 1; }, 100); const loadDelays = await sampleLoop(SECONDS * 1000); clearInterval(emitter); await Promise.all(calls); await new Promise((r) => setTimeout(r, 1000)); const loadCpu = process.cpuUsage(cpu0); const unrelatedPosts = lists().length - posts0; // Real task change: retitle the sub-agent workspace; latency from the API call to the post. const latencies: number[] = []; const posts1 = lists().length; for (let i = 0; i < 10; i++) { const before = lists().length; const t0 = performance.now(); await live.workspace.updateTitle({ workspaceId: child.id, title: `Task ${Date.now()}-${i}` }); await until(() => lists().length > before, "the retitle post"); latencies.push(performance.now() - t0); await new Promise((r) => setTimeout(r, 300)); } await new Promise((r) => setTimeout(r, 1000)); const realPosts = lists().length - posts1; console.log( [ "LIVE5109", `server workspaces: ${all.length}; host list: ${listedCount}; window: ${SECONDS} s`, `idle: CPU ${cpuMs(idleCpu)} ms; event-loop delay ${stats(idleDelays)}`, `load: ${sent} unrelated changes; workspaces posts ${unrelatedPosts}; CPU ${cpuMs(loadCpu)} ms; event-loop delay ${stats(loadDelays)}`, `real: 10 sub-agent retitles; workspaces posts ${realPosts}; latency ${stats(latencies)}`, `handler: ${(globalThis as any).__benchN} calls; skip path ${(globalThis as any).__skipN} calls ${(globalThis as any).__skipMs?.toFixed(2)} ms`, ].join("\n") ); }, 10 * 60_000); }); ``` </details> ## 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 only `name`; 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.ts` reverted to the base commit, the new burst case in `extension.test.ts` fails (`timed out waiting for the end of the burst`); the other 8 cases in the two files pass. ## Risks Low. The host now posts `workspaces` between 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`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=39.94 -->
yermakoffivan
pushed a commit
to yermakoffivan/mux
that referenced
this pull request
Oct 5, 2026
## 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 -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Behavior-neutral refactor and the first layer of the #5109 stack. The sub-agent tasks tray's rendering moves into an exported, props-driven
SubAgentTasksContent(sub-agents, workflow groups, per-descendant activity hints, expanded state, toggle and row-select callbacks).SubAgentTasksDecorationstays the desktop container: it still reads WorkspaceContext and WorkspaceStore, still runs workflow discovery and liveness polling, and now rendersSubAgentTasksContent. Desktop behavior does not change.Refs #5109
Background
The VS Code webview dock has no WorkspaceContext or WorkspaceStore, so it cannot mount
SubAgentTasksDecoration(#5109). The next layer of this stack feedsSubAgentTasksContentfrom host-forwarded data in the webview, as #4996 did forStreamingBarrierContent.Implementation
SubAgentTasksContentholds the JSX and the summary math (active count, workflow summary) that used to sit at the end of the container. The container's hooks and data path are untouched.SubAgentTasksContentis added toHOT_COMPONENTSinscripts/check_react_compiler_coverage.ts, and every existing entry stays.make check-react-compilerreports23/24 hot components compile (1 known skipped); the skipped one is the existingProjectSidebarInnerbaseline, andKNOWN_SKIPPEDis unchanged.Validation
SubAgentTasksContent.test.tsxpins the props contract. With no data it renders nothing. It derives the summary (an armed monitor keeps a reported child active and shows "Monitoring"), toggles, and callsonNavigatewith the row's workspace ID. Before the change it fails withSyntaxError: Export named 'SubAgentTasksContent' not found in module '.../SubAgentTasksDecoration.tsx'.SubAgentTasksDecoration.test.tspasses unchanged.App/PersistentSubagentsstories (Expandedat 1200 px,Phoneat 390 px,BackgroundMonitorat 1200 px,BackgroundMonitorPhoneat 390 px) with the tray expanded, first with this branch and then with the origin/main version of the file. The tray'souterHTMLwas byte-identical in all four. The screenshots below are from this branch.Phone (390 px), this branch:
Laptop (1200 px), this branch:
Risks
Low. The container's subscriptions, effects and polling are unchanged. Only the render moved behind a component boundary, which the compiler gate covers.
Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:high• Cost:$2.45