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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user