Skip to content

feat(chat): show delegated children inside the chat that spawned them - #986

Open
JaimeMarques wants to merge 1 commit into
fathah:mainfrom
JaimeMarques:feat/chat-subagent-roster
Open

JaimeMarques wants to merge 1 commit into
fathah:mainfrom
JaimeMarques:feat/chat-subagent-roster

Conversation

@JaimeMarques

Copy link
Copy Markdown
Contributor

Fixes #985

A delegate_task fan-out created a child session per worker, and the desktop rendered nothing about that work in the chat that started it — while each child showed up as its own sidebar chat. This puts delegated children inside the conversation that spawned them.

What changed

src/renderer/src/screens/Chat/subagents.ts (new) — the projection from gateway payload to display row:

  • folds the relayed subagent.start / subagent.progress / subagent.thinking / subagent.tool / subagent.complete events into one row per subagent_id, per session, behind a useSyncExternalStore snapshot (the array identity only changes when the rows change);
  • reconcileSubagentSnapshot merges the subagent.list roster for a window opened mid-run, without replacing the stream a live event already appended.

Two rules keep it honest:

  • A completion event is terminal. An unrecognized or still-running status on subagent.complete renders as failed rather than leaving a dead row spinning (timeout/error → failed, cancelled/canceled → interrupted). A hard timeout with only duration_seconds gets a synthesized summary.
  • Absence is weak evidence. subagent.start is relayed before the child registers in the roster registry, so an empty snapshot must not retire the row a live event just created; the roster only retires a live row when it is answering about other children, and a row whose id was derived (payload had no subagent_id) is never retired by absence at all.

src/renderer/src/screens/Chat/SubagentSection.tsx (new) — the roster above the composer: goal, elapsed time, live activity line, and on expand the child's stream, its live output tail (subagent.tail, polled only while the row is expanded and the window is visible) plus steer and stop controls. Only live rows render; finished children are retired at the next turn boundary (pruneFinishedSubagents), while children that outlived their turn stay visible.

useDashboardChatTransport.ts — intercepts subagent.* before the message-stream adapter (child activity is roster state, not transcript content), keys the roster by the durable session id the chat is showing, and scopes the four roster RPCs to this conversation's runtime session so a control can never address whichever session happens to be active. list answers empty before the first turn instead of erroring.

Chat.tsx renders the section in .chat-input-area; main.css carries its styles; en/pt-PT/pt-BR get the new strings (other locales fall back to en); lat.md/subagents.md documents the design.

Verification

  • npm run lint, npm run typecheck, lat check — clean.
  • npx vitest run src/renderer/src/screens/Chat/ — 316 passed.
  • Live wire capture: driving a real delegate_task fan-out through the exact transport this app uses (hermes dashboard --no-open + /api/ws, the launch path in src/main/hermes.ts) confirms the frames arrive on the parent session id with the fields the projection reads, and that the RPCs answer there (subagent.list → roster, subagent.tail → live transcript, subagent.steer → {"status":"queued"}). The captured payloads are pinned in subagents.test.ts so the projection is tested against the real wire shape rather than an idealized one.

Suite note: the only failures in a full npm test run are 2 pre-existing wallet-hydration cases in screens/Office/RepInteractionPanel.test.tsx, which fail identically on main without this branch (verified by stashing).

Not in this PR

Child sessions are still listed in the sidebar — that is the follow-up PR (keep parent_session_id IS NOT NULL rows out of listSessions), split out to keep this review to one piece of behaviour.

How to see it

npm run dev, open a chat, and ask for something that delegates ("spawn two subagents that each run echo alpha"): the composer shows a roster while they work, expanding a row shows its stream/transcript, and steer/stop act on that child.

A delegate_task fan-out creates a child session per worker. The desktop listed
every session row as its own conversation and rendered nothing about the work in
the chat that started it, so a delegated child looked like a separate chat while
the parent chat stayed silent about what it had launched.

The gateway already relays each child's activity to the PARENT session as
`subagent.*` events, and serves `subagent.list` / `subagent.steer` /
`subagent.interrupt` / `subagent.tail` over the same /api/ws transport this app
already uses. This adds the in-chat roster that consumes them:

- `subagents.ts` projects the relayed events into one row per child and
  reconciles the `subagent.list` roster for windows opened mid-run. A completion
  with an unrecognized status renders as failed instead of a row that spins
  forever, and an empty roster snapshot never retires the row a live event just
  created (`subagent.start` is relayed before the child registers).
- `SubagentSection` renders live children above the composer — goal, elapsed
  time, latest activity — and on expand the child's stream, its live output tail
  and steer/stop controls.
- The transport intercepts `subagent.*` before the message-stream adapter (child
  activity is roster state, not transcript content) and scopes every roster RPC
  to this conversation's runtime session.

Verified with lint, typecheck, `lat check`, and the Chat suite (316 tests). The
payloads pinned in `subagents.test.ts` were captured from a live /api/ws
delegate_task run.
@greptile-apps

greptile-apps Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 1/5

[Medium risk] Adds in-chat display of delegated child agent activity.

The PR should not merge until roster reconciliation, conversation scoping, and composer overflow are addressed.

Findings

  1. P1 New child disappears ▶
  2. P1 Ended child keeps running ▶
  3. P1 Child rows cross conversations ▶
  4. P1 Roster displaces the composer ▶

Summary

The PR adds a session-scoped projection of delegated-child events, an expandable roster above the chat composer, and runtime-session-scoped list and control RPCs. It also adds translations, design documentation, and projection, transport, and UI tests.

  • Reconciliation can remove a newly started child or retain a child that has ended.
  • The roster key omits the connection and profile that distinguish conversations.
  • The roster needs an aggregate size or scroll constraint to keep the composer accessible.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Gateway["Parent-session subagent events"] --> Transport["Dashboard transport"]
  Transport --> Store["Per-session roster projection"]
  RPC["subagent.list"] --> Reconcile["Snapshot reconciliation"]
  Reconcile --> Store
  Store --> Section["Subagent section above composer"]
  Section --> Controls["Steer, stop, and tail RPCs"]
  Controls --> Gateway
Loading

Reviews (1) · Last reviewed commit: "feat(chat): show delegated children insi..."

Comment on lines +390 to +397
const rosterKnowsChildren = ids.size > 0;
const next = previous.filter(
(item) =>
TERMINAL.has(item.status) ||
item.provisional === true ||
!rosterKnowsChildren ||
ids.has(item.id),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New child disappears A child's start event arrives before it registers in the roster. If another child is already registered, a roster response during that gap is nonempty but omits the new child. This filter then deletes the new child's live row, removing it and its steer and stop controls from the parent chat until another event or roster response restores it.

Comment on lines +390 to +396
const rosterKnowsChildren = ids.size > 0;
const next = previous.filter(
(item) =>
TERMINAL.has(item.status) ||
item.provisional === true ||
!rosterKnowsChildren ||
ids.has(item.id),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Ended child keeps running If the event connection misses a child's completion and the roster later returns empty, this condition preserves the row as running. Later empty polls and turn-boundary pruning cannot remove it, so the parent chat continues to show activity and offer controls for work that has ended.

const TOOL_PREVIEW_MAX = 96;
const EMPTY: SubagentProgress[] = [];

const bySession = new Map<string, SubagentProgress[]>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Child rows cross conversations If two mounted chats on different connections or profiles have the same durable session ID, this global map makes them share child rows because its key omits connection and profile. A child from one chat can appear in the other, where its controls send requests through the other chat's runtime session.

Knowledge Base Used:

Comment on lines +5796 to +5805
.subagent-section {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm, 6px);
background: var(--bg-tertiary, rgba(255, 255, 255, 0.04));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Roster displaces the composer In a short window with several live children, the roster can grow beyond the available chat height; expanding a row adds a stream and an output pane. The roster sits above the composer inside an input area that cannot shrink or scroll, while the enclosing content pane clips overflow. As a result, the message area can disappear and the composer can be pushed out of reach.

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.

Delegated subagents appear as separate sidebar chats instead of inside the chat that spawned them

1 participant