Files
Gabriel Vidal 0fa7000a50 feat(subagents): support nested subagents at any depth
Claude Code writes every agent a conversation spawns into the one flat
`<convId>/subagents/` dir, naming the spawner in the meta sidecar's
`parentAgentId`. The viewer assumed one level: a subagent's own page never
linked its Task cards (the grandchildren), and the root listed depth-2
agents as orphans.

- backend: `_root_path_of` vs `_parent_path_of` (via `parentAgentId`),
  `_subagent_children` / `_subagent_descendants` / `_parent_chain_of`;
  `_attach_subagents` links cards on every page and sends `parentChain`
  + `spawnDepth`; running state resolves up the chain; the root rolls
  the whole tree up, an agent page reports its own spend; a Task card
  is credited with its child's subtree; `/api/subagents` gets `rootId`
  + `depth` with `parentId` the direct parent; agent runs key to the root.
- frontend: breadcrumb banner root › agent › … on a nested page, the
  Parent agents drawer card lists the chain, subagent rows show a
  `+N` badge for what they spawned in turn, the graph orbits a nested
  agent around its parent agent node; mocks seed a depth-2 agent.
- docs: CLAUDE.md "Subagents nest" section; regenerated API types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:17:03 +02:00
..

src/mock/ — the in-browser mock backend

A fake backend that runs in the page, behind the same orval-generated DTOs the real one serves. It exists so the whole PWA can be demoed and driven by an automated frontend test with no FastAPI, no sidecar, no transcripts on disk.

npm run dev:mock            # Vite with the mock forced on (VITE_MOCK=1)
# …or, against any build (including the deployed one):
open https://ai-agent.lab.gabvdl.xyz/?mock=1     # sticky until ?mock=0

How it works

installMock() (called from main.tsx before the app tree is imported, since several modules fetch at import time) swaps two globals:

  • window.fetch — every same-origin /api/* request is answered by handlers.ts from the in-memory database; anything else falls through to the real fetch.
  • window.EventSource — /api/events is served by a tiny hub that emits the same {type: "meta" | "transcript", ids} events backend/events.py does, so the app's SSE-driven React Query invalidation runs for real.
file role
enabled.ts the ?mock=1 / VITE_MOCK flag (tiny — the only part in the main bundle)
db.ts the database: one object, persisted to localStorage, plus the SSE hub and the detail→summary projections
seed.ts the combinatorial fixture (below)
handlers.ts the routing table — every /api/* route, reads and writes
sim.ts spawn / resume / interrupt: a scripted turn that streams
index.ts installs the interceptors, window.__mock, the MOCK API badge

Writes really mutate the db (and persist): archiving a conversation, editing a file, saving settings (the server-backed Zustand stores hit /api/ui-state/*), uploading an attachment. A spawn or resume actually runs — items are appended to the conversation one at a time, each publishing a transcript event, until the run stamps notified + a message ending in DONE and flips to finished. interrupt drops the rest of the script and records an interruption.

The seed is combinatorial

seed.ts doesn't hand-write a few plausible rows — it enumerates the cross-product of every field the UI branches on, so every visual state is reachable from a cold seed:

  • conversations — harness (claude, pi) × state (running, finished, idle) × lifecycle (nothing / committed+pushed / merged+deployed+notified), plus an archived one per harness, a subagent conversation linked to its parent, an empty transcript, and a kitchen-sink thread that contains every ThreadItemKind (text, thinking, tool_use, tool_result, interrupted, paused), every role, an error result, and one card per rendered tool: Read (+ image and screenshot widgets), Edit, Write (plan widget), Bash (+ a deploy URL), Grep, Glob, WebSearch, WebFetch, Skill, Task (subagent), TaskCreate.
  • files — every FileEntryKind × editable × estimated.
  • services — every auth value (auth, public, api, mixed, none) × router kind (http, tcp).
  • projects — with/without costs, url, package.json; both OgImage kinds.
  • plans — every status (proposed / approved / in-progress / done), half with an actual rollup. memories — one per memory type. diffs — every FileDiffStatus and DiffLineType, plus a binary and a truncated file.
  • agents — a scheduled repo agent (the seeded cron job points at its definition), a never-run one, a project-local one, and a built-in type with no file, plus the runs behind their counters in all three origins (task / cron / manual).

It is deterministic (fixed clock, no Math.random), so a test can assert on ids, counts and totals.

Test hooks

window.__mock.db()          // the live database object
window.__mock.reset()       // wipe localStorage + reseed
window.__mock.speed(0)      // 0 = don't stream on a timer…
window.__mock.flush()       // …land every in-flight run instantly (deterministic)
window.__mock.publish({type: "meta"})   // fire an SSE event by hand

A Puppeteer/Playwright pass therefore needs no fixtures of its own: load /?mock=1, walk the routes, POST /api/spawn, flush(), assert.

Keeping it honest

The mock's rows are typed with the generated src/generated/model types (re-exported by types.ts), so a backend schema change that lands in openapi.json → npm run gen:api breaks the seed at tsc time instead of silently drifting from the API it pretends to be.