From 1333d9528080dc3b3528dbaa0bf8d9ff6359d529 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 02:59:57 -0700 Subject: [PATCH] fix(native-chat): stop the subagent roster vanishing from every settled turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NativeChatToolRun` bailed out for a completed turn whose activity disclosure is collapsed before it reached the branch that draws a roster-only run. That guard exists to push TOOL activity behind the turn-status disclosure, and it fires on exactly the shape a spawn group has: a roster message carries no tool blocks, so `selectActiveToolCall` returns null and `isSettled` is true, while the list passes `expandOverride={expandedTurnIds.has(turnKey)}` — false until the reader opens that turn — and `activeTurnIsWorking={false}`. That is the default state of every finished turn in the transcript, so the one compact row this feature exists to leave behind ("Ran 3 subagents") disappeared the moment its turn ended. Worse, `MessageRow` counts a spawn group as renderable specifically so the row survives, then rendered a wrapper around a component that returned null — the empty ghost bubble its own guard is written to prevent. Order the roster branch before the disclosure guard. A roster has no tool activity to hide, and the guard's reasoning ("a failed child command looked like the whole response was still running") does not reach it. Runs that do carry tool blocks still fall through to the guard unchanged, and in practice a roster never shares a message with them: it is its own `role: 'system'` journal row and `isToolOnlyMessage` is false for it, so `foldToolMessages` never merges tool blocks into it. Also drop childless groups when building the rows, so `subagentRows.length` stays an honest test of "something will draw" — the roster-only branch returns a margin-bearing wrapper on the strength of it, and a group with no children renders null. Both tests fail with their fix reverted; the existing NativeChatToolRun suite still passes, so the completed-turn disclosure behaviour is unchanged. --- .../NativeChatSubagentRun.test.tsx | 37 ++++++++++++++++++ .../native-chat/NativeChatToolRun.tsx | 38 ++++++++++++------- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx index c4aeb758b91..f2237c33cc5 100644 --- a/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx @@ -232,6 +232,43 @@ describe('NativeChatToolRun with a spawn group', () => { expect(screen.queryByText('1 tool call')).toBeNull() }) + // Every settled turn sits here by default: the list passes + // `expandOverride={expandedTurnIds.has(turnKey)}` — false until the reader + // opens that turn — and `activeTurnIsWorking={false}`. The completed-turn + // guard above bailed before the roster branch, so the one row this feature + // exists to draw vanished the moment its turn finished, and the message row + // that kept itself alive for it rendered an empty ghost bubble. + it('keeps the roster visible on a completed turn whose activity is collapsed', () => { + render( + + ) + + expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() + }) + + // The roster-only branch returns a `mt-3` wrapper whenever it has rows, so a + // group that draws nothing must not count as one — that wrapper would be the + // empty bubble with a margin that the message row refuses to emit. + it('draws nothing at all for a spawn group that carries no children', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + it('renders the roster alongside the tool activity of its turn', () => { render( setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal]) - const subagentRows = subagentGroups.map((group) => ( - - )) + // Childless groups are dropped so `subagentRows.length` stays an honest test of + // "something will draw": the roster-only branch below returns a margin-bearing + // wrapper on the strength of it, and a group with no children renders null. + const subagentRows = subagentGroups + .filter((group) => group.agents.length > 0) + .map((group) => ( + + )) const callCount = countToolCalls(blocks) || blocks.length const summary = summarizeToolRun(blocks) const latestActiveCall = structuredActivityUi @@ -241,6 +246,19 @@ export function NativeChatToolRun({ value0: callCount }) + // A roster with no tool calls beside it is the whole run: rendering the tool + // header too would announce "1 tool call" for activity that has none. + // + // Ordered BEFORE the completed-turn guard below on purpose. That guard hides + // TOOL activity behind the turn-status disclosure, and a roster row has none + // to hide: it is the compact summary this row exists to leave behind. Bailing + // there instead dropped it from every settled turn — the default state of the + // whole transcript — and left the caller, which counts a spawn group as + // renderable, drawing the empty bubble it explicitly guards against. + if (blocks.length === 0) { + return subagentRows.length > 0 ?
{subagentRows}
: null + } + // Completed turn activity belongs behind the turn-status disclosure. Keeping // the grouped row visible here made a failed child command look like the // whole response was still running (or had failed) even while collapsed. @@ -253,12 +271,6 @@ export function NativeChatToolRun({ return null } - // A roster with no tool calls beside it is the whole run: rendering the tool - // header too would announce "1 tool call" for activity that has none. - if (blocks.length === 0) { - return subagentRows.length > 0 ?
{subagentRows}
: null - } - return ( // Extra top margin sets the tool run apart from the assistant prose above it // so the turn's activity doesn't crowd the message text.