Skip to content

Agents panel: see what subagents are doing, plus Tasks tool support - #324

Merged
frenchie4111 merged 7 commits into
mainfrom
chat-subagent-display
Oct 11, 2026
Merged

frenchie4111 merged 7 commits into
mainfrom
chat-subagent-display

Conversation

@frenchie4111

Copy link
Copy Markdown
Collaborator

Chat mode had no way to see what sub-agents were up to. A session that fans out a dozen of them showed a dozen collapsed Task cards scattered through the transcript, and once you scrolled past, nothing told you what was still running.

Agents panel

A new agents right-column panel. collectAgents (shared/state/json-claude-agents.ts) derives every sub-agent a session launched, at any depth, from entries + backgroundAgents — a derived selector, so nothing new goes over the wire for a second client.

  • Tree. The ▸/▾ chevron reveals nested agents under their parent; clicking the label opens that agent's history in a peek flyout. Rows with a running descendant default to open so a live agent is never buried, with the user's click overriding that — stored as an override rather than as the state, so auto-expand can't reopen a row someone deliberately closed.
  • At a glance. Running agents sort first (oldest first, so the longest-running doesn't jump as siblings settle), each with a one-line "currently" showing its latest step, elapsed time, and tool count.
  • Peek. A flyout with the agent's whole activity feed, following the tail while it streams, markdown report at the bottom, and nested agents linking through to their own peek.
  • Interrupted is reported rather than left spinning: a dead agent that never settles would otherwise show as running forever.

Tasks tool

Ness only knew TodoWrite, so a session driving the newer Tasks tool showed an empty Todos panel and raw JSON in the transcript. Unlike TodoWrite, TaskCreate/TaskUpdate mutate one task at a time, so collectTasks replays the calls instead of reading the last one — recovering ids from the create results, mirroring the blocks/blockedBy graph, and surfacing owner and activeForm. The panel renders Tasks when present and falls back to TodoWrite; keeping the todos panel key means saved per-repo panel config still applies. TaskCreate/TaskUpdate also get a real card instead of GenericToolCard.

Two bugs this surfaced in existing code

Both came from conflating two questions that have two different sources. Every sub-agent runs detached — verified across two live sessions, where all 24 Agent calls resolved immediately with "Async agent launched successfully" and not one passed run_in_background.

question source drives
did the user ask for detachment? run_in_background the BG badge
has the agent finished? task_started / task_notification the status
  • TaskCard keyed the badge off the presence of a lifecycle record. Claude Code emits task_started for every sub-agent, so every Task card was labelled "background".
  • Keying the status off the tool_result marks every agent complete the moment it launches, because that result is always a launch stub. The stub is now recognised and dropped, so it can neither settle an agent nor be rendered as its report.

Performance

The agents list is never purged — it grows for the life of a session — and the panel re-derives on a 500 ms tick while anything is live, so every tick was rebuilding the step feed of agents that finished long ago. collectAgents now takes the caller's previous result and hands settled agents back by reference, which also keeps their object identity stable so React skips those rows. Reuse is refused for an agent whose report hasn't landed yet (task_notification sends status and summary as two events), and for interrupted, which is inferred from !busy rather than reported.

Both panels derive off entry count rather than session identity, which churns per streamed token — a full walk per token is CLAUDE.md anti-pattern #4.

Verification

npm run typecheck, npx electron-vite build and npx vitest run all clean — 1817 tests, 59 new, covering nesting depth, orphan promotion, sibling ordering, running-ancestor marking, stub handling, the reuse cache and its mutation hazards, and the Tasks replay.

Exercised in dev against live sessions, which is how the badge, status and tree-ordering bugs were found. The reducer-level logic is well covered; the React components have no automated tests (this repo has no component-test setup), so the panel bodies rest on manual use.

Two displays for work happening outside the main transcript.

Agents panel (new `agents` right-column panel). `collectAgents` in
`shared/state/json-claude-agents.ts` derives every sub-agent a session
launched — synchronous or background, at any nesting depth — from
entries + backgroundAgents. Nothing new goes over the wire. The panel
lists them running-first with a live "currently" line, elapsed time and
status; clicking one opens a peek flyout with its whole activity feed,
following the tail as it streams, and nested agents link through.

A dead agent is reported as `interrupted` rather than left spinning:
a sync sub-agent can't outlive its parent's turn, so no result once the
turn is over means the run was cut short.

Tasks tool (TaskCreate/TaskUpdate). Ness only knew TodoWrite, so a
session driving the newer Tasks tool showed an empty Todos panel and
raw JSON in the transcript. Unlike TodoWrite these mutate one task at a
time, so `collectTasks` replays the calls instead of reading the last
one — recovering ids from the create results, mirroring the
blocks/blockedBy graph, and surfacing owner and activeForm. The Todos
panel renders Tasks when present and falls back to TodoWrite; keeping
the `todos` panel key means saved per-repo panel config still applies.

Both panels derive off entry count rather than session identity, which
churns per streamed token — a full walk per token is CLAUDE.md
anti-pattern #4. The agents panel adds a 500ms tick for in-place
streaming updates, gated on something actually being live.

41 new reducer-level tests; full suite green.
…panel

The agents list is never purged — it grows for the life of a session —
and the panel re-derives on a 500ms tick while anything is live. So
every tick was rebuilding the step feed of every agent the session ever
launched, including ones that finished long ago and can never change.
Allocation churn rather than a leak, but it scales with session length
and it's the renderer-side GC churn main-process telemetry can't see.

collectAgents now takes the caller's previous result and hands settled
agents straight back by reference, skipping their step rebuild and
keeping object identity stable so React skips those rows too. Reuse is
refused for a settled agent whose result hasn't landed yet, because
task_notification dispatches the status and the real summary as two
events — caching in between would pin the launch stub as the report
forever.

The approval-bubbling pass no longer assigns onto summaries in place: a
reused entry is the same object the caller still holds, so mutating it
would write through to their copy. It computes a set and rebuilds only
the entries whose value actually changed. This is a live path, not a
hypothetical — a background child outlives its parent's turn, so a
finished parent can still acquire a pending approval from below.

Panel-side, finished agents now collapse behind a "show N more" toggle
after the first five. They're history — the transcript keeps their Task
cards — and dozens of them would push the live agents the panel exists
to surface off the top.
…gent list

Claude Code emits system/task_started for EVERY sub-agent, synchronous
ones included. Both the Agents panel and the transcript's Task card
treated the presence of that lifecycle record as proof of detachment,
so every single agent got a "background" badge — verified against a
session whose seven Agent calls all passed no run_in_background at all.
On the Task card it also drove the spinner off the wrong lifecycle.

Only the caller's own run_in_background decides it now. The record is
still used for usage totals and completion time, which means
synchronous agents gain token/tool/duration figures they never showed.

The panel also listed nested agents inline, indented. Sorting is by
status then recency, so a child routinely sorted *above* its parent: an
indented row pointing at nothing, promising a tree the ordering doesn't
deliver. The list is flat now — top-level agents, plus anything running
at any depth so nothing live is ever hidden — and a parent shows a
nested count instead. A running nested agent names its parent on its
secondary line. Full hierarchy stays in the peek, which links through.

Also: rows carry a tooltip explaining what the bare duration means, and
the header reads "N running" / "N done" rather than an unlabelled count.
Clicking a row now toggles it open with a ▸/▾ disclosure, matching the
idiom ToolGroup, TaskCard and ContextPanel already use, instead of
jumping straight to the modal. Expanded shows the tail of the agent's
step feed, its report once finished, and a link through to the full
peek — which is still where the whole feed and the parent→child
hierarchy live.

Several rows can be open at once, since comparing two running agents
side by side is the main reason to open one. The panel's max height
grows while anything is expanded; an expanded row is about twelve lines
and at the collapsed height it would be a scrollbar showing three.

The collapsed "currently" line stays for running agents, so the panel
still reads at a glance, and is dropped when expanded because the feed
below ends on that same step.
The chevron now reveals an agent's nested agents rather than its step
feed, and clicking the row still opens its history in the peek — the
file-tree split, where disclosure is structure and the label is the
thing. Replaces the previous reading, where ▸ expanded an inline step
list and the peek had to be reached through a link.

This also retires the flatten-the-list workaround. Nested agents were
being hoisted to the top level because sorting by status then recency
put a child above its parent, leaving an indented row pointing at
nothing. Rendering an actual tree means a child sits under its parent
by construction, so the indent finally means what it looks like, and
nothing has to be hidden to keep the ordering honest.

buildAgentTree regroups the flat result, preserving running-first order
within each sibling group and promoting an orphan (parent truncated out
of the buffer) to a root rather than dropping it. It also reports which
agents have a running descendant: those default to open so a live agent
is never buried in a collapsed parent, with the user's own click
overriding that — recorded as overrides rather than as the state, so
auto-expand can't fight someone who deliberately closed a row.

Only top-level rows are capped by "show N more finished". A nested one
is revealed by its parent's chevron, so hiding it behind a second cap
would make the tree lie about what it contains.
Running agents showed a green tick. Claude Code runs EVERY sub-agent
detached — verified across two live sessions, where all 24 Agent calls
resolved immediately with "Async agent launched successfully" and not
one passed run_in_background. There is no synchronous path: the
tool_result is always a stub, superseded later by the real answer on
task_notification.

The previous commit keyed status off run_in_background, so with that
flag absent every agent took the result-based branch and the stub read
as a successful result — complete the instant it launched.

These are two questions with two sources, and conflating them has now
broken this in both directions:

  did the user ASK for detachment  -> run_in_background   (the badge)
  has the agent FINISHED           -> task_* lifecycle    (the status)

Status comes from the lifecycle record whenever there is one, falling
back to the tool_result only when there isn't — a build predating the
records, a seeded transcript, or a launch that failed outright (unknown
subagent_type) and so never started a task. The stub is recognised and
dropped, so it can neither settle an agent nor be rendered as its
report. Same fix in TaskCard, which had both bugs too.

Also: `interrupted` is no longer cacheable by the reuse path. It's
inferred from !busy rather than reported, so caching it froze an agent
that way permanently instead of letting it return to running.
The Todos panel stopped appearing. Keying its memo on entries.length
assumed a tool call always appends an entry — it doesn't.
assistantBlockAppended adds the tool_use block to the assistant entry
already in flight, so a TodoWrite / TaskCreate call lands without the
array growing and the panel never re-derives. It sat empty for the
whole turn. Before memoization it derived every render, which is why
this only broke when the memo went in.

The Agents panel had the identical flaw, hidden by its 500ms tick
papering over it within half a second.

Both now share useSessionRevision, which keys on a constant-time
signature — session id, entry count, and the last entry's id and block
count — so an appended block registers immediately. The slow poll
stays as a backstop for mutations the signature can't see: a
tool_result patched into an existing block, or a block appended to an
entry that isn't last because a sub-agent is interleaving. Those now
land within half a second rather than never.

Walking the entries to hash them properly would cost exactly what the
memo exists to avoid, hence a signature rather than real change
detection, with the poll covering the gap.
@frenchie4111
frenchie4111 merged commit c181319 into main Oct 11, 2026
1 check passed
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