claude: give each spawned subagent its own collapsing card (T-342)
A fan-out of N agents (Task/Agent) merged into one shared "Activity / N steps" cluster — groupConversation folded an Agent spawn like any Bash/ Read call. Now an Agent spawn is a cluster boundary, rendering as its own first-class collapsing card (reusing the existing sticky-agent path: folded prompt T-263 + nested run T-264), while adjacent non-agent foldables keep clustering into the normal Activity card. Two changes: - activity_cluster: a shared isAgentTool() predicate; _isFoldable returns false for agent spawns at every level (incl. L3), so parallel agents never merge. Only the grouping boundary changes; fold mechanics are unchanged. - conversation_view: harden resolveOwner. Its nearest-preceding-agent fallback is safe with one agent but mis-routes under a parallel fan-out (an unattributable item lands in whichever agent was emitted last — a sibling's card). With >1 agent, drop the fallback so the item orphans (rendered inline) instead of cross-attributed. The T-338 direct route (parent_tool_use_id) still attributes interleaved items correctly. Tests: two consecutive agents → two cards (not one cluster); agent breaks a sibling cluster; agents first-class at L3; regression — consecutive Bash still one cluster; interleaved parallel-agent runs route to their own card; an unattributable item orphans instead of being swept into the last agent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3530,3 +3530,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'ready', 'in_progress', NULL, '2026-06-11 10:49:33', '2026-06-11 10:49:33', '2026-06-11 10:49:33', NULL, '6180a1491ff29428974ca84c0de4ebb0', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB44SKPKTHFMV6WD28GZYPXM', 'status', 'in_progress', 'done', NULL, '2026-06-11 11:27:00', '2026-06-11 11:27:00', '2026-06-11 11:27:00', NULL, 'b0cf6bb97619a79a419caf4287fe2a54', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'in_progress', 'in_progress', NULL, '2026-06-11 11:27:07', '2026-06-11 11:27:07', '2026-06-11 11:27:07', NULL, '00d5d9b5cf592f3a3fc63fbef7a8b8f2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'status', 'in_progress', 'done', NULL, '2026-06-11 12:35:27', '2026-06-11 12:35:27', '2026-06-11 12:35:27', NULL, '3426869e05daf251e562d77373d55752', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'status', 'in_progress', 'in_progress', NULL, '2026-06-11 12:35:27', '2026-06-11 12:35:27', '2026-06-11 12:35:27', NULL, '4f776a2c08d43710d7dadf9bb3a4b71f', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -3328,3 +3328,41 @@ Where to look:
|
||||
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
|
||||
|
||||
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 11:27:07', NULL, 'c1a9ecdf23d66e94b96feb08d6f93d60', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'ExitPlanMode does not exit plan mode in the conversation panel', 'Observed live: Claude used the ExitPlanMode tool (approved the plan / left plan mode), but the clide conversation panel did not reflect the exit — it still appears to be in plan mode afterward.
|
||||
|
||||
Expected: once ExitPlanMode is accepted, the pane leaves plan mode — the permission-mode indicator (claude_status / permission_mode_control) flips off ''plan'', and the composer / status reflect the new mode.
|
||||
|
||||
Where to look:
|
||||
- How the stream-json session handles the ExitPlanMode tool/control event (stream_json_session.dart) and whether it updates the tracked permission mode (claude_status.dart permissionMode).
|
||||
- The permission-mode control + status badge wiring (permission_mode_control.dart, claude_pane.dart) — does an ExitPlanMode resolution feed back into the mode the way Ctrl/Cmd+M and the picker do?
|
||||
- Whether ExitPlanMode arrives as a permission prompt (ToolPromptCard) and, if approved, actually transitions the session out of plan mode rather than just resolving the tool.
|
||||
|
||||
Repro: enter plan mode, let Claude present a plan via ExitPlanMode, accept it — the pane stays showing plan mode.
|
||||
|
||||
Acceptance: accepting ExitPlanMode leaves plan mode in the conversation panel (mode indicator + behavior update), matching what the underlying session does.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:27:36', '2026-06-11 12:35:27', NULL, '8bf60e7ca53d32f5ccfabdbfea4dd84d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB493JEW32CH0H3771TNHF7G', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Give each spawned subagent its own collapsing activity card (don''t merge Task spawns)', 'Filed 2026-06-10 from a user request: when the agent fans out multiple subagents (Task/Agent tool), each spawned subagent should get its OWN collapsing activity card. The space is worth it — a fan-out of N agents should read as N cards, not one lumped card.
|
||||
|
||||
CURRENT BEHAVIOUR (confirmed): multiple Task/Agent spawns are MERGED into a single shared ''Activity / N steps'' cluster. groupConversation() in lib/builtin/claude/src/activity_cluster.dart:120-147 walks items and coalesces every consecutive _isFoldable item into one FoldedCluster. _isFoldable (:149-172, AssistantToolUse case at :161-164) only distinguishes diff tools (Edit/Write/MultiEdit/NotebookEdit/Update -> stay first-class) from everything else (Task/Agent/Bash/Read/... -> all foldable). The Task tool is treated identically to a Bash/Read call; there is NO subagent-aware grouping key (not toolUseId, not parent_tool_use_id). So 4 spawned agents render as one ''Activity 4 steps'' card.
|
||||
|
||||
What recent work already does (do NOT redo): T-263 folds the subagent PROMPT into the Agent card; T-264 nests the subagent''s RUN items under the parent Agent card (the ''agent run'' collapser); T-338 routes sidechain items to their parent via parent_tool_use_id. All of that is about what shows INSIDE one agent''s card. This ticket is the complement: stop merging DISTINCT agent spawns into a shared cluster.
|
||||
|
||||
SCOPE / DESIGN:
|
||||
- An AssistantToolUse where _isAgentTool(name) (Task/Agent) should break the current Activity cluster and render as its own first-class collapsing card (its own ClideCollapserCard with the prompt + nested ''agent run'' from T-263/T-264), rather than folding into the generic Activity cluster with sibling tool calls.
|
||||
- Decide grouping precisely in groupConversation/_isFoldable: an Agent tool-use is a cluster boundary (like a sticky item) OR emits its own single-item card. Adjacent non-agent foldables (Bash/Read/Grep) keep clustering into the normal Activity card as today.
|
||||
- Label each subagent card by its task/description (the Agent call''s label) so parallel fan-outs are distinguishable, not ''Activity N steps''.
|
||||
- Keep collapsed-by-default behaviour and the FoldLevel semantics; this changes the grouping boundary, not the fold mechanics.
|
||||
|
||||
Refs: lib/builtin/claude/src/activity_cluster.dart (groupConversation, _isFoldable, isDiffTool), lib/builtin/claude/src/conversation_view.dart (_ActivityCard at ~833, _toolUseCollapser ~615-657, _isAgentTool ~378). Related: T-230 (clustering), T-263, T-264, T-338.
|
||||
|
||||
Tests: a groupConversation case asserting that two consecutive Agent tool-uses yield two separate cards (not one FoldedCluster), while two consecutive Bash calls still yield one Activity cluster.
|
||||
|
||||
SCOPE CLARIFICATION (2026-06-10, from user):
|
||||
|
||||
1. RIGHT card, not just A card. Each per-agent card must pull in ALL of that agent''s nested run — the folded prompt (T-263) AND every nested response: prose, thinking, sidechain tool cards, and results (T-264) — attributed to the CORRECT agent even under a parallel fan-out where multiple agents'' sidechain items interleave in the stream. Reuse the existing _sidechainFold machinery (conversation_view.dart:188-256): runByToolUseId / promptsByToolUseId are already keyed by the owning Agent''s toolUseId, and resolveOwner''s direct route (conversation_view.dart:210-217) uses parent_tool_use_id (T-338) which disambiguates concurrent agents correctly. THE HAZARD: resolveOwner falls back to ''nearest'' = lastAgent (the most-recently-emitted Agent in stream order; conversation_view.dart:228, applied at :242). In a parallel fan-out any item that lacks parent_tool_use_id and a rooted parentUuid chain would mis-route to whichever agent was emitted last — landing in the WRONG card. Harden this: for the multi-agent case, drop or guard the nearest-lastAgent fallback so an unattributable item is rendered inline/orphaned (resolveOwner already returns null -> handled at :243) rather than mis-filed into a sibling agent''s card.
|
||||
|
||||
2. PRESERVE the existing grouping. This ticket only adds an Agent-spawn cluster boundary; it must NOT regress the rest:
|
||||
- Non-agent foldables (Bash/Read/Grep/LS/etc.) keep coalescing into the generic ''Activity / N steps'' cluster exactly as today (activity_cluster.dart groupConversation/_isFoldable).
|
||||
- The intra-agent folding stays: prompt-into-call (T-263), run-nested-under-card (T-264), sidechain routing by parent_tool_use_id (T-338). Reuse them; do not rebuild.
|
||||
- Net behaviour: a fan-out of N agents -> N distinct collapsed cards, each containing its own complete run; surrounding non-agent tool calls still group into their normal Activity card.
|
||||
|
||||
Test additions: (a) two concurrent agents whose sidechain items interleave -> each agent''s run items land under its own card, none cross-attributed; (b) an unattributable sidechain item (no parent_tool_use_id, broken chain) is NOT swept into the nearest agent''s card; (c) regression: consecutive Bash/Read calls still form one Activity cluster.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 15:26:33', '2026-06-11 12:35:27', NULL, 'a1faa7af007b8cb1952fb7595e720c4a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -16,6 +16,16 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Each spawned subagent gets its own collapsing activity card.** A fan-out of
|
||||
N agents (Task/Agent tool) used to merge into one shared "Activity / N steps"
|
||||
cluster; now every agent spawn is its own first-class collapsing card with its
|
||||
folded prompt and nested run, labelled by its task. Surrounding non-agent tool
|
||||
calls (Bash/Read/Grep) still group into the normal Activity card. Under a
|
||||
parallel fan-out, an item that can't be attributed to a specific agent now
|
||||
renders inline rather than being mis-filed into a sibling agent's card. (T-342)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
|
||||
|
||||
@@ -74,6 +74,14 @@ final class EditRun extends RenderGroup {
|
||||
/// Tools whose result is a diff the user wants to keep first-class at L1/L2.
|
||||
bool isDiffTool(String name) => const {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Update'}.contains(name);
|
||||
|
||||
/// Tool names that spawn a sub-agent (sidechain): Claude Code emits `Task`,
|
||||
/// the Agent SDK surface uses `Agent`. An agent spawn is ALWAYS its own
|
||||
/// first-class collapsing card — it breaks the Activity cluster so a fan-out
|
||||
/// of N agents reads as N cards, never one merged "Activity / N steps" card
|
||||
/// (T-342). Each card carries its own folded prompt (T-263) + nested run
|
||||
/// (T-264); the fold mechanics are unchanged, only the grouping boundary.
|
||||
bool isAgentTool(String name) => name == 'Task' || name == 'Agent';
|
||||
|
||||
/// The file an edit tool-use targets, or null if [it] isn't a same-file edit
|
||||
/// (used to group consecutive edits, T-296).
|
||||
String? editFilePath(ConversationItem it) {
|
||||
@@ -159,6 +167,10 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
|
||||
case AssistantThinkingMessage():
|
||||
return level != FoldLevel.tools;
|
||||
case AssistantToolUse(:final name):
|
||||
// An Agent/Task spawn is always its own first-class card (T-342) — it
|
||||
// breaks the cluster at every level, including L3, so parallel agents
|
||||
// never merge into one Activity card.
|
||||
if (isAgentTool(name)) return false;
|
||||
// The Edit/Write call stays first-class with its diff at L1/L2.
|
||||
if (level == FoldLevel.everything) return true;
|
||||
return !isDiffTool(name);
|
||||
|
||||
@@ -205,6 +205,15 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
if (it is AssistantToolUse) toolUseIds.add(it.toolUseId);
|
||||
}
|
||||
|
||||
// With more than one agent in the turn (a parallel fan-out), the
|
||||
// "nearest preceding agent" fallback is unsafe: an item with no
|
||||
// parent_tool_use_id and no rooted parentUuid chain would mis-file into
|
||||
// whichever agent was emitted last — landing in a SIBLING agent's card.
|
||||
// Drop the fallback in that case so an unattributable item orphans
|
||||
// (rendered inline) rather than cross-attributed (T-342). A single agent
|
||||
// has only one possible owner, so the fallback stays safe there.
|
||||
final multipleAgents = agentByToolUseId.length > 1;
|
||||
|
||||
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
|
||||
// Direct route: stream-json hands us the spawning Agent's tool-use id on
|
||||
// the item itself (T-338) — no chain to walk.
|
||||
@@ -223,7 +232,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
if (sidechainByUuid[parent] != true) break; // left the run's chain
|
||||
cur = parent;
|
||||
}
|
||||
return nearest;
|
||||
return multipleAgents ? null : nearest;
|
||||
}
|
||||
|
||||
final owned = <String>{};
|
||||
@@ -371,9 +380,9 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
/// used for the "claude" message card's stripe + label.
|
||||
const claudeAccent = Color(0xFFD97757);
|
||||
|
||||
/// The tool names that launch a sub-agent (sidechain). Claude Code emits
|
||||
/// `Task`; the Agent SDK surface uses `Agent` — accept both (T-263).
|
||||
bool _isAgentTool(String name) => name == 'Task' || name == 'Agent';
|
||||
/// The tool names that launch a sub-agent (sidechain) — shared with the
|
||||
/// grouping pass so "is this an agent spawn?" has one definition (T-342).
|
||||
bool _isAgentTool(String name) => isAgentTool(name);
|
||||
|
||||
/// Open a governance/ticket record clicked in the conversation (T-279) in its
|
||||
/// context-pane reader, reusing the existing `selection` MessageBus addressing
|
||||
|
||||
@@ -155,4 +155,34 @@ void main() {
|
||||
expect(out.map((g) => g.runtimeType.toString()), ['StickyItem', 'StickyItem', 'StickyItem']);
|
||||
});
|
||||
});
|
||||
|
||||
group('agent spawns are their own card (T-342)', () {
|
||||
test('two consecutive Agent spawns yield two separate cards, not one cluster', () {
|
||||
final groups = groupConversation([_tool('1', 'Task'), _tool('2', 'Task')], FoldLevel.tools);
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups.every((g) => g is StickyItem), isTrue);
|
||||
});
|
||||
|
||||
test('an agent spawn breaks an Activity cluster of sibling tools', () {
|
||||
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _tool('2', 'Task'), _tool('3', 'Bash'), _result('3')], FoldLevel.tools);
|
||||
expect(groups.map((g) => g.runtimeType.toString()), ['FoldedCluster', 'StickyItem', 'FoldedCluster']);
|
||||
expect(((groups[1] as StickyItem).item as AssistantToolUse).name, 'Task');
|
||||
});
|
||||
|
||||
test("the SDK 'Agent' tool is treated as an agent spawn too", () {
|
||||
expect(groupConversation([_tool('1', 'Agent')], FoldLevel.tools).single, isA<StickyItem>());
|
||||
});
|
||||
|
||||
test('agents stay first-class even at L3 (everything), so parallel agents never merge', () {
|
||||
final groups = groupConversation([_tool('1', 'Task'), _tool('2', 'Task')], FoldLevel.everything);
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups.every((g) => g is StickyItem), isTrue);
|
||||
});
|
||||
|
||||
test('regression: consecutive Bash calls still form one Activity cluster', () {
|
||||
final groups = groupConversation([_tool('1', 'Bash'), _result('1'), _tool('2', 'Bash'), _result('2')], FoldLevel.tools);
|
||||
expect(groups, hasLength(1));
|
||||
expect((groups.single as FoldedCluster).items, hasLength(4));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -533,6 +533,36 @@ void main() {
|
||||
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('parallel agents: interleaved run items route by parentToolUseId to their own card (T-342)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'A'}),
|
||||
AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'B'}),
|
||||
// Sidechain prose for the two agents, interleaved + tagged with the
|
||||
// owning agent's tool-use id (T-338 direct route).
|
||||
AssistantTextMessage(uuid: 'rB', timestamp: _t, isSidechain: true, parentToolUseId: 'tB', text: 'FROM B'),
|
||||
AssistantTextMessage(uuid: 'rA', timestamp: _t, isSidechain: true, parentToolUseId: 'tA', text: 'FROM A'),
|
||||
]);
|
||||
// Each agent gets its own 1-step run, not one pooled 2-step run under the
|
||||
// last-emitted agent — proof the interleaved items routed by their own
|
||||
// parentToolUseId (pooling would show one "2 steps" run, zero "1 step").
|
||||
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsNWidgets(2));
|
||||
expect(find.bySemanticsLabel('agent run, 2 steps, collapsed'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('parallel agents: an unattributable sidechain item orphans, not swept into the last agent (T-342)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'A'}),
|
||||
AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'B'}),
|
||||
// No parentToolUseId and no rooted parentUuid chain — unattributable.
|
||||
AssistantTextMessage(uuid: 'lost', timestamp: _t, isSidechain: true, text: 'UNROUTED PROSE'),
|
||||
]);
|
||||
// With >1 agent the nearest-agent fallback is dropped, so this orphans and
|
||||
// renders inline as "agent" prose instead of being filed under agent B.
|
||||
expect(find.text('UNROUTED PROSE'), findsOneWidget); // visible inline, not hidden in a collapsed run
|
||||
expect(find.text('agent'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('agent run, 1 step, collapsed'), findsNothing); // neither agent gained a run from it
|
||||
});
|
||||
|
||||
testWidgets('a successful sidechain result folds into its run tool card, not a separate step (T-264)', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'x'}),
|
||||
|
||||
Reference in New Issue
Block a user