fold sub-agent prompt into the Agent card; relabel you → agent prompt (T-263)
A sidechain sub-agent prompt was rendered with the blue "you" label, falsely implying the user typed it. Now: - transcript_reader parses parentUuid (was dropped) onto every ConversationItem. - conversation_view resolves each sidechain prompt to its spawning Agent/Task card via parentUuid (nearest-preceding Agent as fallback), folds the prompt into that card as a collapsed "prompt" segment, and suppresses the standalone block. Layered order when expanded: call → prompt → result (note E). - A sidechain UserMessage never gets the "you" treatment: folded into its card, or — when orphaned — rendered as a muted standalone "agent prompt". Tests: parentUuid parse, fold + suppression, parallel-agent attachment (would fail under a nearest-preceding heuristic), orphan relabel, and a golden for the Agent card's call → prompt → result layering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
/// (its tool-use *and* result echo are noise — the prompt + the logged answer
|
||||
/// cover it), and any permission-prompted tool-use (keep its result — that's
|
||||
/// the useful answer).
|
||||
List<ConversationItem> _visibleItems(List<ConversationItem> items) {
|
||||
List<ConversationItem> _visibleItems(List<ConversationItem> items, Set<String> foldedPromptUuids) {
|
||||
final hidden = widget.hiddenToolUseIds;
|
||||
final auqIds = {
|
||||
for (final it in items)
|
||||
@@ -114,6 +114,9 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
|
||||
bool drop(ConversationItem it) {
|
||||
if (it is AssistantToolUse) return toolUseDropped(it);
|
||||
// T-263: a sidechain agent prompt that folded into its Agent card is
|
||||
// suppressed here so it doesn't also render as a standalone block.
|
||||
if (it is UserMessage) return foldedPromptUuids.contains(it.uuid);
|
||||
if (it is ToolResultMessage) {
|
||||
if (auqIds.contains(it.toolUseId)) return true; // AUQ result echo — noise
|
||||
// T-262: a successful result whose paired tool-use is going to render
|
||||
@@ -134,6 +137,40 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
];
|
||||
}
|
||||
|
||||
/// Resolves which sidechain prompts fold into which Agent/Task card (T-263).
|
||||
///
|
||||
/// A sidechain prompt's owner is the Agent tool-use its `parentUuid` branches
|
||||
/// off — robust when several agents run in parallel in one turn. Falls back to
|
||||
/// the nearest preceding Agent tool-use when the link can't be resolved.
|
||||
/// Returns the prompt uuids to suppress and the prompts grouped by the owning
|
||||
/// tool-use id (so the card can fold them).
|
||||
({Set<String> foldedPromptUuids, Map<String, List<UserMessage>> promptsByToolUseId}) _agentPromptFold(List<ConversationItem> items) {
|
||||
final agentByMsgUuid = <String, AssistantToolUse>{
|
||||
for (final it in items)
|
||||
if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it,
|
||||
};
|
||||
final folded = <String>{};
|
||||
final byToolUseId = <String, List<UserMessage>>{};
|
||||
AssistantToolUse? lastAgent;
|
||||
for (final it in items) {
|
||||
if (it is AssistantToolUse && _isAgentTool(it.name)) {
|
||||
lastAgent = it;
|
||||
continue;
|
||||
}
|
||||
// Only a text user message authored inside a sidechain is an agent
|
||||
// prompt; harness-injected messages and tool results are not.
|
||||
if (it is UserMessage && it.isSidechain && !it.injected) {
|
||||
final viaParent = it.parentUuid != null ? agentByMsgUuid[it.parentUuid] : null;
|
||||
final owner = viaParent ?? lastAgent;
|
||||
if (owner != null) {
|
||||
folded.add(it.uuid);
|
||||
(byToolUseId[owner.toolUseId] ??= <UserMessage>[]).add(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (foldedPromptUuids: folded, promptsByToolUseId: byToolUseId);
|
||||
}
|
||||
|
||||
void _onChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
@@ -148,7 +185,11 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final items = _visibleItems(widget.controller.items);
|
||||
final allItems = widget.controller.items;
|
||||
// T-263: resolve sidechain prompts → owning Agent card before culling, so
|
||||
// the standalone prompt block is suppressed and folded into its card.
|
||||
final promptFold = _agentPromptFold(allItems);
|
||||
final items = _visibleItems(allItems, promptFold.foldedPromptUuids);
|
||||
|
||||
if (items.isEmpty) {
|
||||
return ColoredBox(
|
||||
@@ -161,7 +202,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
// fold a successful result in. Built from the full item list (not the
|
||||
// visible one — the success result is suppressed from `items`).
|
||||
final resultByToolUseId = <String, ToolResultMessage>{
|
||||
for (final it in widget.controller.items)
|
||||
for (final it in allItems)
|
||||
if (it is ToolResultMessage) it.toolUseId: it,
|
||||
};
|
||||
|
||||
@@ -183,6 +224,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptFold.promptsByToolUseId,
|
||||
),
|
||||
FoldedCluster(:final items) => _ActivityCard(
|
||||
items: items,
|
||||
@@ -190,6 +232,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
toolUseById: widget.controller.toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptFold.promptsByToolUseId,
|
||||
),
|
||||
};
|
||||
},
|
||||
@@ -206,6 +249,10 @@ 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';
|
||||
|
||||
/// One conversation item, rendered by kind.
|
||||
class _ConversationTurn extends StatelessWidget {
|
||||
const _ConversationTurn({
|
||||
@@ -214,6 +261,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.toolUseById = const <String, AssistantToolUse>{},
|
||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||
});
|
||||
|
||||
final ConversationItem item;
|
||||
@@ -227,30 +275,35 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// successful result into one merged card (T-262).
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
|
||||
/// Index from an Agent/Task toolUseId → the sidechain prompt(s) it owns, so
|
||||
/// the Agent card can fold its prompt in (T-263).
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = item;
|
||||
return switch (i) {
|
||||
UserMessage() => i.injected
|
||||
// Harness-injected (skill load / command expansion / system
|
||||
// reminder) — not typed by the user, so de-emphasise: a muted,
|
||||
// collapsed "context" card rather than the "you" accent (D-78).
|
||||
? ConversationCard(
|
||||
variant: ConversationCardVariant.bare,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: 'context',
|
||||
copyText: i.text,
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
collapsedSummary: _firstLine(i.text),
|
||||
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
|
||||
)
|
||||
: ConversationCard(
|
||||
accent: tokens.globalFocus,
|
||||
label: 'you',
|
||||
copyText: i.text,
|
||||
body: ClideMarkdown(i.text),
|
||||
),
|
||||
// Harness-injected (skill load / command expansion / system reminder) and
|
||||
// sidechain agent prompts are both NOT typed by the user, so de-emphasise:
|
||||
// a muted, collapsed card rather than the blue "you" accent (D-78). A
|
||||
// sidechain prompt here is an orphan one (its Agent card couldn't be
|
||||
// resolved) — folded prompts are suppressed upstream (T-263).
|
||||
UserMessage() when i.injected || i.isSidechain => ConversationCard(
|
||||
variant: ConversationCardVariant.bare,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: i.isSidechain ? 'agent prompt' : 'context',
|
||||
copyText: i.text,
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
collapsedSummary: _firstLine(i.text),
|
||||
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
UserMessage() => ConversationCard(
|
||||
accent: tokens.globalFocus,
|
||||
label: 'you',
|
||||
copyText: i.text,
|
||||
body: ClideMarkdown(i.text),
|
||||
),
|
||||
AssistantTextMessage() => ConversationCard(
|
||||
accent: claudeAccent,
|
||||
label: 'claude',
|
||||
@@ -356,8 +409,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
final result = resultByToolUseId[t.toolUseId];
|
||||
final succeeded = result != null && !result.isError;
|
||||
final status = result == null ? ConversationCardStatus.none : (result.isError ? ConversationCardStatus.error : ConversationCardStatus.success);
|
||||
final segments =
|
||||
succeeded ? [CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)))] : const <CardSegment>[];
|
||||
// T-263: an Agent/Task card folds its sub-agent prompt(s) in. Layered order
|
||||
// when expanded (note E): call input (body) → prompt → returned result.
|
||||
final segments = <CardSegment>[
|
||||
for (final p in promptsByToolUseId[t.toolUseId] ?? const <UserMessage>[])
|
||||
CardSegment(label: 'prompt', child: ClideText(p.text, muted: true, fontSize: clideFontMeta)),
|
||||
if (succeeded) CardSegment(label: 'result', child: ClideCodeBlock(source: result.content, language: _resultLanguage(t))),
|
||||
];
|
||||
|
||||
// A resolved permission-prompted call: collapsed, green if approved / red
|
||||
// if denied — a quiet record of what was permitted (D-78). It still folds
|
||||
@@ -495,6 +553,7 @@ class _ActivityCard extends StatefulWidget {
|
||||
required this.toolUseOutcomes,
|
||||
required this.toolUseById,
|
||||
required this.resultByToolUseId,
|
||||
required this.promptsByToolUseId,
|
||||
});
|
||||
|
||||
final List<ConversationItem> items;
|
||||
@@ -502,6 +561,7 @@ class _ActivityCard extends StatefulWidget {
|
||||
final Map<String, bool> toolUseOutcomes;
|
||||
final Map<String, AssistantToolUse> toolUseById;
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
|
||||
@override
|
||||
State<_ActivityCard> createState() => _ActivityCardState();
|
||||
@@ -570,6 +630,7 @@ class _ActivityCardState extends State<_ActivityCard> {
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
toolUseById: widget.toolUseById,
|
||||
resultByToolUseId: widget.resultByToolUseId,
|
||||
promptsByToolUseId: widget.promptsByToolUseId,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -39,11 +39,17 @@ import 'dart:isolate';
|
||||
|
||||
/// Discriminated union of conversation items the reader can emit.
|
||||
sealed class ConversationItem {
|
||||
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain});
|
||||
const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain, this.parentUuid});
|
||||
|
||||
final String uuid;
|
||||
final DateTime timestamp;
|
||||
final bool isSidechain;
|
||||
|
||||
/// The uuid of this record's predecessor in its chain (from the transcript
|
||||
/// envelope's `parentUuid`), or null when absent/empty. A sidechain prompt
|
||||
/// branches off the assistant message that issued its spawning Agent/Task
|
||||
/// tool-use, so this links the prompt to the right Agent card (T-263).
|
||||
final String? parentUuid;
|
||||
}
|
||||
|
||||
/// A user-typed message (plain text, possibly multi-part).
|
||||
@@ -52,6 +58,7 @@ final class UserMessage extends ConversationItem {
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
required this.text,
|
||||
this.injected = false,
|
||||
});
|
||||
@@ -74,6 +81,7 @@ final class ToolResultMessage extends ConversationItem {
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
required this.toolUseId,
|
||||
required this.content,
|
||||
required this.isError,
|
||||
@@ -93,6 +101,7 @@ final class AssistantTextMessage extends ConversationItem {
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
@@ -108,6 +117,7 @@ final class AssistantThinkingMessage extends ConversationItem {
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
required this.thinking,
|
||||
});
|
||||
|
||||
@@ -123,6 +133,7 @@ final class AssistantToolUse extends ConversationItem {
|
||||
required super.uuid,
|
||||
required super.timestamp,
|
||||
required super.isSidechain,
|
||||
super.parentUuid,
|
||||
required this.toolUseId,
|
||||
required this.name,
|
||||
required this.input,
|
||||
@@ -545,6 +556,8 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
||||
|
||||
final uuid = envelope['uuid'] as String? ?? '';
|
||||
final isSidechain = envelope['isSidechain'] as bool? ?? false;
|
||||
final rawParent = envelope['parentUuid'] as String?;
|
||||
final parentUuid = (rawParent != null && rawParent.isNotEmpty) ? rawParent : null;
|
||||
|
||||
DateTime timestamp;
|
||||
try {
|
||||
@@ -555,9 +568,9 @@ void _parseLineInto(String line, List<ConversationItem> out, List<String> warnin
|
||||
|
||||
switch (type) {
|
||||
case 'user':
|
||||
_parseUserInto(envelope, uuid, timestamp, isSidechain, out);
|
||||
_parseUserInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
|
||||
case 'assistant':
|
||||
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, out);
|
||||
_parseAssistantInto(envelope, uuid, timestamp, isSidechain, parentUuid, out);
|
||||
_extractAssistantStatus(envelope, status);
|
||||
default:
|
||||
break; // unknown type — degrade gracefully
|
||||
@@ -582,6 +595,7 @@ void _parseUserInto(
|
||||
String uuid,
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
String? parentUuid,
|
||||
List<ConversationItem> out,
|
||||
) {
|
||||
final message = envelope['message'] as Map?;
|
||||
@@ -595,7 +609,7 @@ void _parseUserInto(
|
||||
|
||||
if (content is String) {
|
||||
if (content.isNotEmpty) {
|
||||
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: content, injected: injected));
|
||||
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: content, injected: injected));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -614,6 +628,7 @@ void _parseUserInto(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
toolUseId: item['tool_use_id'] as String? ?? '',
|
||||
content: rawContent is String ? rawContent : jsonEncode(rawContent),
|
||||
isError: item['is_error'] as bool? ?? false,
|
||||
@@ -623,7 +638,7 @@ void _parseUserInto(
|
||||
}
|
||||
}
|
||||
if (textParts.isNotEmpty) {
|
||||
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n'), injected: injected));
|
||||
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: textParts.join('\n'), injected: injected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +647,7 @@ void _parseAssistantInto(
|
||||
String uuid,
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
String? parentUuid,
|
||||
List<ConversationItem> out,
|
||||
) {
|
||||
final message = envelope['message'] as Map?;
|
||||
@@ -645,12 +661,12 @@ void _parseAssistantInto(
|
||||
case 'text':
|
||||
final text = item['text'] as String? ?? '';
|
||||
if (text.isNotEmpty) {
|
||||
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: text));
|
||||
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, text: text));
|
||||
}
|
||||
case 'thinking':
|
||||
final thinking = item['thinking'] as String? ?? '';
|
||||
if (thinking.isNotEmpty) {
|
||||
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, thinking: thinking));
|
||||
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, parentUuid: parentUuid, thinking: thinking));
|
||||
}
|
||||
case 'tool_use':
|
||||
final rawInput = item['input'];
|
||||
@@ -658,6 +674,7 @@ void _parseAssistantInto(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
parentUuid: parentUuid,
|
||||
toolUseId: item['id'] as String? ?? '',
|
||||
name: item['name'] as String? ?? '',
|
||||
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
||||
|
||||
Reference in New Issue
Block a user