feat(chat): show delegated children inside the chat that spawned them - #986
JaimeMarques wants to merge 1 commit into
Conversation
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.
|
| const rosterKnowsChildren = ids.size > 0; | ||
| const next = previous.filter( | ||
| (item) => | ||
| TERMINAL.has(item.status) || | ||
| item.provisional === true || | ||
| !rosterKnowsChildren || | ||
| ids.has(item.id), | ||
| ); |
There was a problem hiding this comment.
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.
| const rosterKnowsChildren = ids.size > 0; | ||
| const next = previous.filter( | ||
| (item) => | ||
| TERMINAL.has(item.status) || | ||
| item.provisional === true || | ||
| !rosterKnowsChildren || | ||
| ids.has(item.id), |
There was a problem hiding this comment.
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[]>(); |
There was a problem hiding this comment.
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:
| .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)); | ||
| } |
There was a problem hiding this comment.
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.
Fixes #985
A
delegate_taskfan-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:subagent.start/subagent.progress/subagent.thinking/subagent.tool/subagent.completeevents into one row persubagent_id, per session, behind auseSyncExternalStoresnapshot (the array identity only changes when the rows change);reconcileSubagentSnapshotmerges thesubagent.listroster for a window opened mid-run, without replacing the stream a live event already appended.Two rules keep it honest:
runningstatus onsubagent.completerenders asfailedrather than leaving a dead row spinning (timeout/error→ failed,cancelled/canceled→ interrupted). A hard timeout with onlyduration_secondsgets a synthesized summary.subagent.startis 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 nosubagent_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— interceptssubagent.*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.listanswers empty before the first turn instead of erroring.Chat.tsxrenders the section in.chat-input-area;main.csscarries its styles;en/pt-PT/pt-BRget the new strings (other locales fall back toen);lat.md/subagents.mddocuments the design.Verification
npm run lint,npm run typecheck,lat check— clean.npx vitest run src/renderer/src/screens/Chat/— 316 passed.delegate_taskfan-out through the exact transport this app uses (hermes dashboard --no-open+/api/ws, the launch path insrc/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 insubagents.test.tsso the projection is tested against the real wire shape rather than an idealized one.Suite note: the only failures in a full
npm testrun are 2 pre-existing wallet-hydration cases inscreens/Office/RepInteractionPanel.test.tsx, which fail identically onmainwithout 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 NULLrows out oflistSessions), 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 runecho alpha"): the composer shows a roster while they work, expanding a row shows its stream/transcript, and steer/stop act on that child.