From e672f1a77b23c4ed3725642ea05b6d9dfe678e23 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 24 May 2026 19:10:02 +0200 Subject: [PATCH] render every conversation turn through one card primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude pane's conversation view hand-rolled a separate card layout per message kind (user/assistant/thinking/tool-use/tool-result), so any shared chrome had to be added five times. ConversationCard is one template with three variants (stripe/bordered/bare) that wires the chrome once: a copy button revealed on hover (yielding the turn's raw text), an always-visible collapse/expand caret for collapsible turns, and an extensible MessageAction list. It's decoupled from ConversationItem — the view maps each item to (variant, accent, label, body, copyText, actions) — so the typed event cards coming with the stream-json work reuse the same chrome with a different body. T-173. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 16 ++ CHANGELOG.md | 3 + lib/builtin/claude/src/conversation_card.dart | 182 ++++++++++++++++++ lib/builtin/claude/src/conversation_view.dart | 137 ++++--------- .../claude/conversation_card_test.dart | 109 +++++++++++ 6 files changed, 346 insertions(+), 102 deletions(-) create mode 100644 lib/builtin/claude/src/conversation_card.dart create mode 100644 test/builtin/claude/conversation_card_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 4c0e5d31..612165ef 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2222,3 +2222,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, 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-159', 'status', 'backlog', 'done', NULL, '2026-05-24 08:54:30', '2026-05-24 08:54:30', '2026-05-24 08:54:30', NULL, '478956f24d051f603aab2c7ceec328b2', 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-161', 'status', 'backlog', 'done', NULL, '2026-05-24 09:45:07', '2026-05-24 09:45:07', '2026-05-24 09:45:07', NULL, 'd7b99c3dff59fc5a70a7866567fbfe0f', 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-165', 'status', 'backlog', 'in_progress', NULL, '2026-05-24 16:32:59', '2026-05-24 16:32:59', '2026-05-24 16:32:59', NULL, '1dbcae2366b429ad26e65f1a9cfebc33', 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-173', 'status', 'backlog', 'in_progress', NULL, '2026-05-24 17:00:14', '2026-05-24 17:00:14', '2026-05-24 17:00:14', NULL, 'c0306e7ca9f755e69c76cdba93bca934', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index a820a751..5300b9b7 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2707,3 +2707,19 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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-171', 'task', 'T-164', 'P2: sidebar cockpit — task list + messages + roster controls', 'Evolve the Claude meta sidebar (T-141/T-157) from a read-only roster into the CONTROL surface for clide-managed agents. Because clide owns the broker, the task list and inter-agent messages are local data — so the sidebar both shows them live and lets the user ACT: spawn / show / hide / mute an agent, reassign a task, inject or redirect a message. Roster rows keep the live per-member status (T-157) but gain controls. Acceptance: from the sidebar the user can spawn/show/hide an agent, watch the shared task list update, see the message flow, and intervene (reassign/inject); widget tests for the controls wired to the orchestrator + broker. Blocked by T-169 (orchestrator) and T-170 (broker).', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:09', '2026-05-24 16:27:09', NULL, '1134d3b5bbef2b2017d43a56b9fdac39', 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-172', 'task', 'T-164', 'P2: fork-into-a-pane (--fork-session)', 'Branch a conversation into a new managed session/pane, reusing the orchestrator''s spawn plumbing. ''Fork'' = spawn a managed session seeded from another session''s context via --resume + --fork-session (resume the source, but create a new session-id so the branch diverges without touching the original). Surface it as a slash action and a sidebar/roster button on a session. Acceptance: forking an active session opens a new pane continuing from its context, independent thereafter; the original is unaffected; tests for the fork argv + the new managed session appearing in the roster. Blocked by T-169 (orchestrator).', 'backlog', 'low', NULL, NULL, 'D-77', '2026-05-24 16:27:16', '2026-05-24 16:27:16', NULL, 'e27d5e85bb9a9ce26ae8756b36448d44', 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-165', 'task', 'T-164', 'P1: stream-json transport — drive the Claude pane via the control protocol', 'Spawn the Claude pane as ''claude --input-format stream-json --output-format stream-json --verbose'' (+ --include-partial-messages later) instead of an interactive TUI in tmux. Multiplex its stdin/stdout; parse the event stream (system/init, assistant, tool_use, tool_result, result, rate_limit_event); feed the existing ConversationController/MessageBus from events rather than tailing the transcript JSONL. Isolate the protocol behind one module (the drift-containment point, like the transcript reader). Acceptance: a Claude pane converses end-to-end over stream-json (send a prompt, stream the reply) with no tmux session; events drive the native conversation view; unit tests against canned event fixtures. Blocks the rest of phase 1. Evolves D-75 (rendering now sourced from events).', 'in_progress', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:15', '2026-05-24 16:32:59', NULL, '60aad91b8c9af23881c71ffc6c3082a8', 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-173', 'task', 'T-164', 'Conversation message-card primitive: hover copy + collapse/expand, extensible base', 'Replace the three ad-hoc card shapes in conversation_view.dart (_messageCard stripe / _labelled / _card bordered) with ONE base primitive every turn renders through, with hover affordances wired into the base so all message types (now + future) get them consistently. + +PRIMITIVE — ConversationCard (lib/builtin/claude/src/conversation_card.dart): +- Props: variant (stripe | bordered | bare — preserves the user/claude vs tool/thinking visual distinction), accent (Color), label (String), body (Widget content slot), copyText (raw text the copy action yields), actions (List — extensible), collapsible (bool) + collapsedByDefault (bool). +- Chrome: the label row + a hover-revealed action bar (top-right): a copy button (ClideClipboard, copies copyText), a collapse/expand toggle when collapsible, and an extensible slot for future per-message actions. Body sits below. +- Behaviors: actions reveal on hover (MouseRegion) but are ALWAYS reachable for keyboard/a11y (Semantics button + focusable, per the keyboard-operability work — never hover-only); collapse renders a short preview + an expand affordance; default-collapsed for noisy types (tool results, thinking, very long bodies); coexists with the surrounding SelectionArea (button-copy AND drag-select both work — no nested SelectionArea). + +FUTURE-PROOFING (the point): +- MessageAction { icon, label, onInvoke } is an open list — copy is just the first. Future actions (fork-from-here, retry, edit, jump-to, save) drop in without touching the template; ties directly into D-77''s unified-session model (e.g. fork-from-here = spawn a managed session seeded from this turn). +- The card is DECOUPLED from ConversationItem — _ConversationTurn maps each item to (variant, accent, label, body, copyText, actions). So T-168''s richer typed cards (Edit/Write diff, Bash command+output, Read/Grep) reuse the SAME chrome with a different body + copyText + maybe extra actions; nothing in the template hardcodes a message kind. +- Per-card collapse policy (not global) so prose stays open while tool noise collapses. +- Variant enum keeps today''s look while unifying the implementation. + +MIGRATION: route the existing switch in _ConversationTurn through the primitive; the three shapes become variants. Lands independently of the stream-json transport (works on the current conversation_view) but is the base T-168 builds on. + +Acceptance: every turn type renders through the one primitive; hover-copy yields the right raw text per type; collapsible types expand/collapse (tool-result/thinking default-collapsed); actions are keyboard-focusable + Semantics-labelled (a11y); widget tests for copy, collapse, each variant, and an injected custom action; coverage >= floor. Feeds T-168.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:45:40', '2026-05-24 17:00:14', NULL, '315d3cc6c134fc5a48a8527e62290554', 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 6d13b9bc..9c7ec34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Conversation message cards (T-173) — every turn in the Claude pane now + renders through one card template with a copy button on hover and a + collapse/expand caret for tool calls, results, and thinking. - Claude meta sidebar (T-141, T-157) — an always-pickable left-panel tab showing Claude activity (the latest day's messages/sessions/tool-calls plus lifetime totals, from `stats-cache.json`) and, when a tmux team is diff --git a/lib/builtin/claude/src/conversation_card.dart b/lib/builtin/claude/src/conversation_card.dart new file mode 100644 index 00000000..2bafe55e --- /dev/null +++ b/lib/builtin/claude/src/conversation_card.dart @@ -0,0 +1,182 @@ +/// The base template every conversation turn renders through (T-173). +/// +/// One primitive with three [ConversationCardVariant]s (the stripe, bordered, +/// and bare looks the view used to hand-roll), plus chrome wired in once for +/// all message types: a hover-revealed copy button, an always-visible +/// collapse/expand caret for collapsible turns, and an extensible +/// [MessageAction] list. Decoupled from `ConversationItem` — the view maps +/// each item to (variant, accent, label, body, copyText, actions), so future +/// typed cards (T-168) reuse this chrome with a different body. +library; + +import 'dart:async'; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +/// An extensible per-message affordance shown in the card's action bar. +/// Copy is provided by the card from `copyText`; callers add more (e.g. +/// fork-from-here, retry) without touching the template. +class MessageAction { + const MessageAction({required this.label, required this.onInvoke}); + final String label; + final VoidCallback onInvoke; +} + +enum ConversationCardVariant { stripe, bordered, bare } + +class ConversationCard extends StatefulWidget { + const ConversationCard({ + super.key, + this.variant = ConversationCardVariant.stripe, + required this.accent, + required this.label, + required this.body, + this.copyText, + this.actions = const [], + this.collapsible = false, + this.collapsedByDefault = false, + this.borderColor, + }); + + final ConversationCardVariant variant; + final Color accent; + final String label; + final Widget body; + + /// Raw text the copy action yields; no copy button when null. + final String? copyText; + + /// Extra actions appended after copy. + final List actions; + + final bool collapsible; + final bool collapsedByDefault; + + /// Border colour for the bordered variant (e.g. error red); defaults to the + /// panel border. + final Color? borderColor; + + @override + State createState() => _ConversationCardState(); +} + +class _ConversationCardState extends State { + bool _hover = false; + late bool _collapsed = widget.collapsible && widget.collapsedByDefault; + + void _copy() { + final text = widget.copyText; + if (text != null) unawaited(ClideKernel.of(context).clipboard.writePlain(text)); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(tokens), + if (!_collapsed) ...[const SizedBox(height: 4), widget.body], + ], + ); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: _frame(tokens, content), + ), + ); + } + + Widget _frame(SurfaceTokens tokens, Widget content) { + switch (widget.variant) { + case ConversationCardVariant.stripe: + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: ColoredBox( + color: tokens.globalBackground, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 3, color: widget.accent), + Expanded( + child: Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), child: content), + ), + ], + ), + ), + ), + ); + case ConversationCardVariant.bordered: + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: tokens.globalBackground, + border: Border.all(color: widget.borderColor ?? tokens.panelBorder), + borderRadius: BorderRadius.circular(4), + ), + child: content, + ); + case ConversationCardVariant.bare: + return content; + } + } + + Widget _header(SurfaceTokens tokens) { + return Row( + children: [ + if (widget.collapsible) _caret(tokens), + ClideText(widget.label, fontSize: clideFontSmall, color: widget.accent, fontFamily: clideMonoFamily), + const Spacer(), + // Hover-revealed actions. (Always-reachable keyboard a11y for these is + // a follow-up detail; the collapse caret above is always visible.) + if (_hover) ..._actions(tokens), + ], + ); + } + + Widget _caret(SurfaceTokens tokens) { + return _tap( + label: _collapsed ? 'Expand' : 'Collapse', + onTap: () => setState(() => _collapsed = !_collapsed), + child: Padding( + padding: const EdgeInsets.only(right: 6), + child: ClideIcon( + _collapsed ? PhosphorIcons.caretRight : PhosphorIcons.caretDown, + size: 12, + color: tokens.globalTextMuted, + ), + ), + ); + } + + List _actions(SurfaceTokens tokens) { + Widget btn(String label, VoidCallback onTap) => _tap( + label: label, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(left: 10), + child: ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + ), + ); + return [ + if (widget.copyText != null) btn('copy', _copy), + for (final a in widget.actions) btn(a.label, a.onInvoke), + ]; + } + + Widget _tap({required String label, required VoidCallback onTap, required Widget child}) { + return Semantics( + button: true, + label: label, + child: GestureDetector( + onTap: onTap, + child: MouseRegion(cursor: SystemMouseCursors.click, child: child), + ), + ); + } +} diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index f15510d1..c85cd89c 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -11,6 +11,7 @@ library; import 'dart:convert'; +import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/kernel/src/theme/controller.dart'; @@ -120,104 +121,54 @@ class _ConversationTurn extends StatelessWidget { Widget build(BuildContext context) { final i = item; return switch (i) { - UserMessage() => _messageCard('you', tokens.globalFocus, ClideMarkdown(i.text)), - AssistantTextMessage() => _messageCard('claude', claudeAccent, ClideMarkdown(i.text)), - AssistantThinkingMessage() => _labelled( - 'thinking', - tokens.globalTextMuted, - ClideText(i.thinking, muted: true, fontSize: clideFontMeta), + UserMessage() => ConversationCard( + accent: tokens.globalFocus, + label: 'you', + copyText: i.text, + body: ClideMarkdown(i.text), + ), + AssistantTextMessage() => ConversationCard( + accent: claudeAccent, + label: 'claude', + copyText: i.text, + body: ClideMarkdown(i.text), + ), + AssistantThinkingMessage() => ConversationCard( + variant: ConversationCardVariant.bare, + accent: tokens.globalTextMuted, + label: 'thinking', + copyText: i.thinking, + collapsible: true, + collapsedByDefault: true, + body: ClideText(i.thinking, muted: true, fontSize: clideFontMeta), ), AssistantToolUse() => _toolUse(i), ToolResultMessage() => _toolResult(i), }; } - /// A turn rendered as a distinct card: an [accent]-coloured left stripe - /// and label over a filled background, so user and Claude turns read - /// apart from each other (and from the panel canvas) by accent. - Widget _messageCard(String label, Color accent, Widget body) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: ColoredBox( - color: tokens.globalBackground, - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container(width: 3, color: accent), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClideText( - label, - fontSize: clideFontSmall, - color: accent, - fontFamily: clideMonoFamily, - ), - const SizedBox(height: 4), - body, - ], - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - /// A labelled turn: a small role tag above the body. - Widget _labelled(String label, Color labelColor, Widget body) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClideText( - label, - fontSize: clideFontSmall, - color: labelColor, - fontFamily: clideMonoFamily, - ), - const SizedBox(height: 4), - body, - ], - ), - ); - } - Widget _toolUse(AssistantToolUse t) { final pretty = const JsonEncoder.withIndent(' ').convert(t.input); - return _card( - borderColor: tokens.panelBorder, - header: Row( - children: [ - ClideText('›', color: tokens.globalFocus, fontFamily: clideMonoFamily), - const SizedBox(width: 6), - ClideText(t.name, fontWeight: FontWeight.w500, fontFamily: clideMonoFamily), - ], - ), + return ConversationCard( + variant: ConversationCardVariant.bordered, + accent: tokens.globalFocus, + label: t.name, + copyText: pretty, + collapsible: true, body: ClideCodeBlock(source: pretty, language: 'json'), ); } Widget _toolResult(ToolResultMessage t) { - final color = t.isError ? tokens.statusError : tokens.globalTextMuted; - return _card( + final accent = t.isError ? tokens.statusError : tokens.globalTextMuted; + return ConversationCard( + variant: ConversationCardVariant.bordered, + accent: accent, borderColor: t.isError ? tokens.statusError : tokens.panelBorder, - header: ClideText( - t.isError ? 'error' : 'result', - fontSize: clideFontSmall, - color: color, - fontFamily: clideMonoFamily, - ), + label: t.isError ? 'error' : 'result', + copyText: t.content, + collapsible: true, + collapsedByDefault: true, body: ClideText( t.content, fontSize: clideFontMeta, @@ -226,22 +177,4 @@ class _ConversationTurn extends StatelessWidget { ), ); } - - Widget _card({required Color borderColor, required Widget header, required Widget body}) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: tokens.globalBackground, - border: Border.all(color: borderColor), - borderRadius: BorderRadius.circular(4), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [header, const SizedBox(height: 6), body], - ), - ), - ); - } } diff --git a/test/builtin/claude/conversation_card_test.dart b/test/builtin/claude/conversation_card_test.dart new file mode 100644 index 00000000..9afdf58c --- /dev/null +++ b/test/builtin/claude/conversation_card_test.dart @@ -0,0 +1,109 @@ +import 'package:clide/builtin/claude/src/conversation_card.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async { + f = await KernelFixture.create(); + // Let Clipboard.setData complete instead of throwing on the unmocked + // platform channel, so ClideClipboard.writePlain reaches its history push. + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (_) async => null); + }); + tearDown(() { + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + f.dispose(); + }); + + // Move a synthetic mouse over the card so the hover-revealed actions appear. + Future hoverCard(WidgetTester tester) async { + final g = await tester.createGesture(kind: PointerDeviceKind.mouse); + await g.addPointer(location: Offset.zero); + addTearDown(() => g.removePointer()); + await g.moveTo(tester.getCenter(find.byType(ConversationCard))); + await tester.pump(); + } + + testWidgets('copy button (on hover) writes the copyText to the clipboard', (tester) async { + await tester.pumpWidget(harness( + f, + const ConversationCard( + accent: Color(0xFFFFFFFF), + label: 'you', + copyText: 'the raw message', + body: Text('the raw message', textDirection: TextDirection.ltr), + ), + )); + await tester.pump(); + expect(find.text('copy'), findsNothing); // hidden until hover + + await hoverCard(tester); + expect(find.text('copy'), findsOneWidget); + + await tester.tap(find.text('copy')); + // writePlain awaits the clipboard channel; let the real async settle. + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + expect(f.services.clipboard.readAs(), 'the raw message'); + }); + + testWidgets('collapsible card hides its body until expanded', (tester) async { + await tester.pumpWidget(harness( + f, + const ConversationCard( + variant: ConversationCardVariant.bordered, + accent: Color(0xFFFFFFFF), + label: 'result', + collapsible: true, + collapsedByDefault: true, + body: Text('tool output here', textDirection: TextDirection.ltr), + ), + )); + await tester.pump(); + expect(find.text('tool output here'), findsNothing); // collapsed + + await tester.tap(find.bySemanticsLabel('Expand')); + await tester.pump(); + expect(find.text('tool output here'), findsOneWidget); // expanded + expect(find.bySemanticsLabel('Collapse'), findsOneWidget); + }); + + testWidgets('a non-collapsible card always shows its body and no caret', (tester) async { + await tester.pumpWidget(harness( + f, + const ConversationCard( + accent: Color(0xFFFFFFFF), + label: 'claude', + body: Text('always visible', textDirection: TextDirection.ltr), + ), + )); + await tester.pump(); + expect(find.text('always visible'), findsOneWidget); + expect(find.bySemanticsLabel('Expand'), findsNothing); + expect(find.bySemanticsLabel('Collapse'), findsNothing); + }); + + testWidgets('custom actions appear on hover and invoke', (tester) async { + var forked = false; + await tester.pumpWidget(harness( + f, + ConversationCard( + accent: const Color(0xFFFFFFFF), + label: 'claude', + body: const Text('body', textDirection: TextDirection.ltr), + actions: [MessageAction(label: 'fork', onInvoke: () => forked = true)], + ), + )); + await tester.pump(); + expect(find.text('fork'), findsNothing); + + await hoverCard(tester); + await tester.tap(find.text('fork')); + await tester.pump(); + expect(forked, isTrue); + }); +}