Skip to content

🤖 refactor: extract props-driven SubAgentTasksContent from the sub-agent tasks tray - #5634

Merged
ThomasK33 merged 1 commit into
mainfrom
xum-5109-subagent-content-split
Oct 4, 2026
Merged

ThomasK33 merged 1 commit into
mainfrom
xum-5109-subagent-content-split

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

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). SubAgentTasksDecoration stays the desktop container: it still reads WorkspaceContext and WorkspaceStore, still runs workflow discovery and liveness polling, and now renders SubAgentTasksContent. 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 feeds SubAgentTasksContent from host-forwarded data in the webview, as #4996 did for StreamingBarrierContent.

Implementation

  • SubAgentTasksContent holds 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.
  • SubAgentTasksContent is added to HOT_COMPONENTS in scripts/check_react_compiler_coverage.ts, and every existing entry stays. make check-react-compiler reports 23/24 hot components compile (1 known skipped); the skipped one is the existing ProjectSidebarInner baseline, and KNOWN_SKIPPED is unchanged.
  • The webview bundle already includes WorkspaceStore, WorkspaceContext, RouterContext and API (checked with an esbuild metafile), so the next layer can import the content component from this file without pulling in new store modules. That is why the code stays in one file and nothing moves.

Validation

  • New SubAgentTasksContent.test.tsx pins 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 calls onNavigate with the row's workspace ID. Before the change it fails with SyntaxError: Export named 'SubAgentTasksContent' not found in module '.../SubAgentTasksDecoration.tsx'.
  • The existing SubAgentTasksDecoration.test.ts passes unchanged.
  • Desktop parity: I loaded the App/PersistentSubagents stories (Expanded at 1200 px, Phone at 390 px, BackgroundMonitor at 1200 px, BackgroundMonitorPhone at 390 px) with the tray expanded, first with this branch and then with the origin/main version of the file. The tray's outerHTML was byte-identical in all four. The screenshots below are from this branch.

Phone (390 px), this branch:

phone after

Laptop (1200 px), this branch:

expanded after

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-04T19:25:34.877379Z 59c0e81 PR opened
🔒 Security Review ✅ Completed 2026-10-04T19:25:20.943438Z 59c0e81 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ThomasK33 ThomasK33 changed the title xum 5109 subagent content split 🤖 refactor: extract props-driven SubAgentTasksContent from the sub-agent tasks tray Oct 4, 2026
@ThomasK33

Copy link
Copy Markdown
Member Author

Readiness record for head 59c0e81fe49b1ead969008adf212ed4cbd73f687:

  • CI: required checks pass. The optional Pixel / Review has 1 snapshot pending; that check is not required.
  • Codex: the automatic normal and security reviews on this head had no findings. No review threads.
  • Review assessments used: 3 of 6 (normal 1, security 1, final independent check 1).
  • Final independent check (clean-context sub-agent): ready, no defects. Criteria checked: behavior-neutral split, compiler gate 23/24 with no new KNOWN_SKIPPED, no eslint-disable or manual memo, 136 changed lines, test fails before the change.
  • Stop reason: all merge gates are met on the exact head. Merging under the coordinator contract. The next layer (webview data path) follows as a stacked PR.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit ba72f89 Oct 4, 2026
30 of 31 checks passed
@ThomasK33
ThomasK33 deleted the xum-5109-subagent-content-split branch October 4, 2026 19:59
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:

![webview
300px](https://github.com/user-attachments/assets/4cf07653-f2e0-49e6-b207-5859fa77adec)

390 px:

![webview
390px](https://github.com/user-attachments/assets/5d5a4dfb-4ee1-4417-96e7-d3255795148e)

900 px (same rows and labels as the desktop tray):

![webview
900px](https://github.com/user-attachments/assets/6d49e23e-e4a6-4f35-8fee-9d3671ab9073)

## 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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant