diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 08d969ac..240c41a6 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -814,3 +814,4 @@ Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', NULL, '2026-06-08 08:34:24', '2026-06-08 08:34:24', '2026-06-08 08:34:24', NULL, '499355d06a5e7618a1c32e63a6cd1bcd', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-277', 'parent_id', NULL, 'T-276', NULL, '2026-06-08 08:34:47', '2026-06-08 08:34:47', '2026-06-08 08:34:47', NULL, '67d78cbf1e92166d60e45b3f51ed440e', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-263', 'status', 'in_progress', 'done', NULL, '2026-06-08 08:45:23', '2026-06-08 08:45:23', '2026-06-08 08:45:23', NULL, '7e18bb85a36603663881ebd55d77d308', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 1f7956ed..193379e9 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1846,3 +1846,30 @@ Scope: Once this lands, loose UI/UX work + bugs can move from the T-276 tracker-epic to a label (e.g. ''ui-tweak''); file that migration as a follow-up — do NOT auto-close T-276 (it''s marked never-close). Relevant: lib/builtin/tickets/, lib/src/pql/ (pql wrapper), and pql''s ''ticket label'' / ''ticket list'' label fields.', 'backlog', 'medium', NULL, NULL, 'D-3', '2026-06-08 08:32:30', '2026-06-08 08:34:47', NULL, 'da5dd0bb879b9d088e009c735700c935', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-263', 'story', 'T-267', 'Fold sub-agent prompt into the Agent card; relabel from you to agent prompt', 'When Claude launches a sub-agent (the Agent/Task tool), the sub-agent conversation runs as a SIDECHAIN. The prompt Claude wrote for that sub-agent comes through the transcript as a UserMessage with isSidechain=true but injected=false. The conversation view does not look at isSidechain, so it renders that prompt with the blue "you" label exactly like real user input — FALSELY implying the user sent it. It sits as a standalone block below the Agent tool-use card. This is misleading: the user did not write that prompt. + +Confirmed facts from the code: every transcript envelope carries BOTH isSidechain AND parentUuid (transcript_reader.dart ~L547 parses isSidechain but never USES it; parentUuid is dropped entirely). The Agent/Task tool-use is rendered by _toolUse in lib/builtin/claude/src/conversation_view.dart (only AskUserQuestion is special-cased today). The "you" vs "context" label is chosen in conversation_view.dart (~L200): UserMessage with injected=true → "context" (muted); else → "you" (blue, tokens.globalFocus). isSidechain is currently never read by any rendering/grouping logic. + +Design (confirmed with user): +1. Never label a sidechain prompt "you". A UserMessage with isSidechain=true is an AGENT PROMPT, not user input — label it "agent prompt" with muted / de-emphasized styling (same precedent as injected "context" messages, D-78). Never the blue "you" treatment. +2. Fold the prompt INTO the Agent tool-use card above it, COLLAPSED BY DEFAULT. The Agent card becomes the container that owns its prompt: expand the card to reveal the prompt; collapsed (default) it is hidden. The standalone prompt block is SUPPRESSED from the list — same fold-a-standalone-item-into-its-card pattern as T-262. +3. Prompt folds into the CALL, not the results. Only the prompt folds into the collapsed Agent call card. The sub-agent work (its thinking / tool calls / results) stays VISIBLE as the results chain after the call — not buried. Shape: [collapsed Agent call, owning the prompt] then [the run results], with no misleading "you" block anywhere. Agent prompts are valuable enough to keep with the call rather than fold into the subsequent results chain. +4. Link the prompt to the RIGHT Agent card via parentUuid. Parse parentUuid (currently dropped) so each sidechain prompt attaches to its spawning Agent tool-use. This matters when several agents run in parallel in one turn — a positional "nearest preceding Agent" heuristic would misattach; the parent link is the robust route. Heuristic only as a fallback when the link cannot be resolved. + +Touch points: +- transcript_reader.dart: capture parentUuid from the envelope and expose it on ConversationItem (it is present in the JSONL, just not parsed). +- conversation_view.dart: relabel sidechain UserMessage to "agent prompt" (stop the "you" path); fold it into the Agent card body; special-case the Agent/Task tool name (today only AskUserQuestion is special-cased); suppress the standalone block. +- activity_cluster.dart: ensure the suppressed prompt is not double-counted in grouping; leave the sidechain results in the chain. + +Edge cases: +- Parallel agents in one turn → each prompt attaches to its own card (the reason for parentUuid). +- Orphan prompt (no resolvable parent) → still render as "agent prompt", muted/collapsed, never "you". +- Nested / background sub-agents. + +Tests: unit/widget coverage for sidechain-prompt relabel (never "you"), folding into the Agent card (collapsed hides, expand reveals), suppression of the standalone block, parentUuid attachment with parallel agents, and the orphan fallback. Add a golden for the Agent card with a folded prompt. + +Refs: D-78 (interaction zone / display-only conversation widgets; injected/context muting precedent). Related: T-262 (fold-standalone-item-into-its-card pattern). + +IMPLEMENTATION NOTE (E — Agent-card layered ordering, from streamlining analysis): + +With this ticket (fold prompt) and T-262 (merge success result) both landing, the Agent/Task card ends up owning multiple segments: the tool INPUT, the folded PROMPT, and — via T-262 — the sub-agents final returned RESULT (the Task ToolResultMessage). Define a deliberate layered order when expanded (e.g. call/input → prompt → returned result) with clear sub-labels/dividers so the Agent card stays readable and does not become a kitchen sink. Coordinate with T-262 (result merge) and T-264 (nesting the whole run): the nested run region vs the returned-result segment must not duplicate the sub-agent output.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:34:02', '2026-06-08 08:45:23', NULL, '561ac6a3308d08c0da27bcb23eb21cc0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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); diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f01af9..b7569f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Changed +- A **sub-agent prompt** is no longer mislabelled as your input: a sidechain + prompt now reads as a muted **`agent prompt`** (never the blue `you`) and folds + into its **Agent/Task card**, collapsed by default. The prompt attaches to the + right card via `parentUuid`, so parallel agents in one turn stay correctly + paired. The sub-agent's work stays visible after the call. (T-263) - A successful tool call now renders as **one merged card** instead of a separate call + result pair: a green check sits at the header's right edge and the output folds in as a colorized code block (Read → file grammar, Bash → diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 21648b7a..f785dfc5 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -93,7 +93,7 @@ class _ConversationViewState extends State { /// (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 _visibleItems(List items) { + List _visibleItems(List items, Set foldedPromptUuids) { final hidden = widget.hiddenToolUseIds; final auqIds = { for (final it in items) @@ -114,6 +114,9 @@ class _ConversationViewState extends State { 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 { ]; } + /// 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 foldedPromptUuids, Map> promptsByToolUseId}) _agentPromptFold(List items) { + final agentByMsgUuid = { + for (final it in items) + if (it is AssistantToolUse && _isAgentTool(it.name)) it.uuid: it, + }; + final folded = {}; + final byToolUseId = >{}; + 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] ??= []).add(it); + } + } + } + return (foldedPromptUuids: folded, promptsByToolUseId: byToolUseId); + } + void _onChanged() { if (!mounted) return; setState(() {}); @@ -148,7 +185,11 @@ class _ConversationViewState extends State { @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 { // fold a successful result in. Built from the full item list (not the // visible one — the success result is suppressed from `items`). final resultByToolUseId = { - 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 { 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 { toolUseOutcomes: widget.toolUseOutcomes, toolUseById: widget.controller.toolUseById, resultByToolUseId: resultByToolUseId, + promptsByToolUseId: promptFold.promptsByToolUseId, ), }; }, @@ -206,6 +249,10 @@ class _ConversationViewState extends State { /// 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 {}, this.toolUseById = const {}, this.resultByToolUseId = const {}, + this.promptsByToolUseId = const >{}, }); final ConversationItem item; @@ -227,30 +275,35 @@ class _ConversationTurn extends StatelessWidget { /// successful result into one merged card (T-262). final Map 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> 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 []; + // 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 = [ + for (final p in promptsByToolUseId[t.toolUseId] ?? const []) + 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 items; @@ -502,6 +561,7 @@ class _ActivityCard extends StatefulWidget { final Map toolUseOutcomes; final Map toolUseById; final Map resultByToolUseId; + final Map> 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, ), ], ), diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index 49149f68..90c3b467 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -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 out, List 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 out, List 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 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 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() : {}, diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 2833617d..48265170 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -282,6 +282,54 @@ void main() { expect(find.text('thanks'), findsOneWidget); }); + testWidgets('a sidechain prompt folds into its Agent card; never labelled "you" (T-263)', (tester) async { + await pumpWith(tester, [ + AssistantToolUse( + uuid: 'agt-msg', timestamp: _t, isSidechain: false, toolUseId: 'task1', name: 'Task', input: const {'description': 'explore the codebase'}), + UserMessage(uuid: 'p1', timestamp: _t, isSidechain: true, text: 'find all the widgets'), + ]); + // Never the blue "you", and no standalone block (folded → suppressed). + expect(find.text('you'), findsNothing); + expect(find.text('agent prompt'), findsNothing); // not a standalone card here + expect(find.text('Task'), findsOneWidget); + // Collapsed by default: the prompt is hidden. + expect(find.text('find all the widgets'), findsNothing); + + // Expand the Agent card → a "prompt" segment reveals the folded prompt. + await tester.tap(find.bySemanticsLabel('Expand')); + await tester.pumpAndSettle(); + expect(find.text('prompt'), findsOneWidget); // segment sub-label + expect(find.text('find all the widgets'), findsOneWidget); + }); + + testWidgets('an orphan sidechain prompt renders as muted "agent prompt", never "you" (T-263)', (tester) async { + // No Agent tool-use to attach to → stays standalone, but relabelled. + await pumpWith(tester, [ + UserMessage(uuid: 'orphan', timestamp: _t, isSidechain: true, text: 'orphaned agent instructions'), + ]); + expect(find.text('you'), findsNothing); + expect(find.text('agent prompt'), findsOneWidget); + }); + + testWidgets('parallel agents: each prompt attaches to its own card via parentUuid (T-263)', (tester) async { + // Document order scrambles the prompts so a nearest-preceding heuristic + // would misattach BOTH to agent B; parentUuid must route them correctly. + await pumpWith(tester, [ + AssistantToolUse(uuid: 'mA', timestamp: _t, isSidechain: false, toolUseId: 'tA', name: 'Task', input: const {'description': 'agent A'}), + AssistantToolUse(uuid: 'mB', timestamp: _t, isSidechain: false, toolUseId: 'tB', name: 'Task', input: const {'description': 'agent B'}), + UserMessage(uuid: 'pB', timestamp: _t, isSidechain: true, parentUuid: 'mB', text: 'PROMPT FOR B'), + UserMessage(uuid: 'pA', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'PROMPT FOR A'), + ]); + // Two collapsed Agent cards; the first Expand caret belongs to card A. + expect(find.text('you'), findsNothing); + await tester.tap(find.bySemanticsLabel('Expand').first); + await tester.pumpAndSettle(); + // Only card A is expanded → its prompt (A) shows; B's stays folded away. + // Nearest-preceding would have put A's prompt under B, revealing nothing. + expect(find.text('PROMPT FOR A'), findsOneWidget); + expect(find.text('PROMPT FOR B'), findsNothing); + }); + testWidgets('a permission-prompted tool-use is hidden but its result is kept', (tester) async { await pumpWith( tester, diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index e65223f4..378081a2 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -361,6 +361,23 @@ void main() { final items = parseAll([raw]); expect(items.first.isSidechain, isTrue); }); + + test('parentUuid is parsed; empty parentUuid normalises to null (T-263)', () { + final withParent = envelope( + type: 'user', + uuid: 'u8', + parentUuid: 'msg-A', + message: {'role': 'user', 'content': 'a sidechain prompt'}, + ); + final withoutParent = envelope( + type: 'user', + uuid: 'u9', + message: {'role': 'user', 'content': 'top-level'}, // parentUuid defaults to '' + ); + final items = parseAll([withParent, withoutParent]); + expect(items[0].parentUuid, 'msg-A'); + expect(items[1].parentUuid, isNull); // '' → null + }); }); // ------------------------------------------------------------------------- diff --git a/test/goldens/conversation_card_goldens_test.dart b/test/goldens/conversation_card_goldens_test.dart index 4a44d54a..e8e53539 100644 --- a/test/goldens/conversation_card_goldens_test.dart +++ b/test/goldens/conversation_card_goldens_test.dart @@ -51,6 +51,25 @@ void main() { ), ), ), + GoldenTestScenario( + name: 'agent / expanded (call → prompt → result layering, T-263)', + child: _wrap( + f, + const ConversationCard( + variant: ConversationCardVariant.bordered, + accent: Color(0xFF4C9AFF), + label: 'Task', + status: ConversationCardStatus.success, + collapsible: true, + collapsedByDefault: false, + body: Text('{ "description": "explore the codebase" }', textDirection: TextDirection.ltr), + extraSegments: [ + CardSegment(label: 'prompt', child: Text('find all the widgets and summarise', textDirection: TextDirection.ltr)), + CardSegment(label: 'result', child: Text('found 42 widgets', textDirection: TextDirection.ltr)), + ], + ), + ), + ), GoldenTestScenario( name: 'error / header mark (call card stays separate from the red result card)', child: _wrap( diff --git a/test/goldens/goldens/linux/conversation_card_merged.png b/test/goldens/goldens/linux/conversation_card_merged.png index a78ae04d..498485a4 100644 Binary files a/test/goldens/goldens/linux/conversation_card_merged.png and b/test/goldens/goldens/linux/conversation_card_merged.png differ