diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 240c41a6..5bb38527 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -815,3 +815,5 @@ 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; +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-266', 'status', 'backlog', 'in_progress', NULL, '2026-06-08 08:47:43', '2026-06-08 08:47:43', '2026-06-08 08:47:43', NULL, '0eebe9e270a6a7a4b9cccfb9ffbe5dc4', 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-266', 'status', 'in_progress', 'done', NULL, '2026-06-08 09:04:12', '2026-06-08 09:04:12', '2026-06-08 09:04:12', NULL, 'fecc7bc78e18d5dd3c613d6583371fc3', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 193379e9..d123cec7 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1873,3 +1873,63 @@ Refs: D-78 (interaction zone / display-only conversation widgets; injected/conte 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); +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-266', 'story', 'T-267', 'Restyle activity/holder card as a container of sub-cards (fix collapse-control scroll race)', 'Restyle the activity card (and, via a shared primitive, the nested sub-agent run from T-264) so a folded run reads as ONE container that holds its sub-cards, with a reachable collapse/expand affordance that survives live writes. + +DESIGN RESOLVED (with user) — supersedes the earlier design-pending note. + +1. HOLDER = a shared container primitive. Extract one container widget (e.g. a ClideHolderCard / conversation-level container) that renders a titled/attributed frame WRAPPING its child sub-cards. Both the activity card (_ActivityCard, lib/builtin/claude/src/conversation_view.dart ~L427) and the nested sub-agent run (T-264) consume it, so the container model is settled once. NOTE: this makes T-266 a DEPENDENCY of T-264 (the shared primitive must exist before the agent-run nesting can consume it) — update epic T-267 sequencing: T-266''s primitive lands before/with T-264, no longer strictly last. + +2. Toggle = the HOLDER BACKGROUND, not the children. Clicking the container''s own background/chrome (its padding, the gaps between sub-cards, its gutter — any region NOT covered by a child) toggles collapse/expand. Crucially: +- Taps on a child sub-card lying on top of the background MUST NOT toggle the holder. Child cards opaquely consume their own hit region across their FULL bounds (including their body), so a click on a sub-card interacts with that card, never the holder. +- The copy button (and any child control / ClideTappable) keeps its own interaction — never swallowed by the holder toggle. +- Implementation: a background gesture target behind the children that only fires for hits the children do not consume (HitTestBehavior; children opaque over their bounds). No whole-card overlay that would intercept child taps. + +3. Autoscroll stays ALWAYS-FOLLOW. Keep the current tail-follow (conversation_view._onChanged ~L119 jumps to maxScrollExtent on every change). Reachability of the collapse control no longer depends on getting to a top header: because the toggle is the ever-present holder BACKGROUND, a click on whatever background area is currently in view (near the latest content while following) collapses the holder — ending the race. Once collapsed the card shrinks to its ticker, so the scroll-yank stops. + +4. Keep the collapsed ticker. Collapsed (default) still shows the one-line live ticker (latest step) + step count, as today. Expanded shows the contained sub-cards. + +5. Accessibility (obligation): a background tap is not keyboard/AT reachable on its own, so keep an EXPLICIT focusable, Enter/Space-activatable collapse control (e.g. the ticker row remains a Semantics button / a focusable caret) in addition to the background-click affordance. Announce expanded/collapsed + step count (as _ActivityCard does today). + +Touch points: +- New shared container primitive (extracted from _ActivityCard). +- conversation_view.dart: _ActivityCard adopts the primitive; background-toggle wiring; ensure child ConversationCards opaquely consume their bounds so they do not bubble taps to the holder. +- T-264 consumes the same primitive for the nested agent run. + +Edge cases: +- A holder whose child is itself collapsible (a tool card): tapping the child''s caret/body toggles the CHILD, never the holder. +- Text selection inside a child (ClideSelectionArea wraps the list): selection drags on child bodies must not be hijacked by the holder toggle — another reason the holder responds only to its own background, and only to taps (not drags). +- Empty / single-item holder. + +Tests: holder background tap toggles; tap on a child sub-card does NOT toggle the holder; copy button still copies (not swallowed); keyboard/AT path toggles via the explicit control; collapsed ticker + step count preserved. Add a golden for the holder container (collapsed + expanded). + +Refs: D-78. Provides the shared container consumed by T-264. Related: T-230 (activity card), T-262 / T-263 / T-265. Parent: T-267.', 'in_progress', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:41:13', '2026-06-08 08:47:43', NULL, 'c983e4459c327d71c9783c06286f4b49', 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-266', 'story', 'T-267', 'Restyle activity/holder card as a container of sub-cards (fix collapse-control scroll race)', 'Restyle the activity card (and, via a shared primitive, the nested sub-agent run from T-264) so a folded run reads as ONE container that holds its sub-cards, with a reachable collapse/expand affordance that survives live writes. + +DESIGN RESOLVED (with user) — supersedes the earlier design-pending note. + +1. HOLDER = a shared container primitive. Extract one container widget (e.g. a ClideHolderCard / conversation-level container) that renders a titled/attributed frame WRAPPING its child sub-cards. Both the activity card (_ActivityCard, lib/builtin/claude/src/conversation_view.dart ~L427) and the nested sub-agent run (T-264) consume it, so the container model is settled once. NOTE: this makes T-266 a DEPENDENCY of T-264 (the shared primitive must exist before the agent-run nesting can consume it) — update epic T-267 sequencing: T-266''s primitive lands before/with T-264, no longer strictly last. + +2. Toggle = the HOLDER BACKGROUND, not the children. Clicking the container''s own background/chrome (its padding, the gaps between sub-cards, its gutter — any region NOT covered by a child) toggles collapse/expand. Crucially: +- Taps on a child sub-card lying on top of the background MUST NOT toggle the holder. Child cards opaquely consume their own hit region across their FULL bounds (including their body), so a click on a sub-card interacts with that card, never the holder. +- The copy button (and any child control / ClideTappable) keeps its own interaction — never swallowed by the holder toggle. +- Implementation: a background gesture target behind the children that only fires for hits the children do not consume (HitTestBehavior; children opaque over their bounds). No whole-card overlay that would intercept child taps. + +3. Autoscroll stays ALWAYS-FOLLOW. Keep the current tail-follow (conversation_view._onChanged ~L119 jumps to maxScrollExtent on every change). Reachability of the collapse control no longer depends on getting to a top header: because the toggle is the ever-present holder BACKGROUND, a click on whatever background area is currently in view (near the latest content while following) collapses the holder — ending the race. Once collapsed the card shrinks to its ticker, so the scroll-yank stops. + +4. Keep the collapsed ticker. Collapsed (default) still shows the one-line live ticker (latest step) + step count, as today. Expanded shows the contained sub-cards. + +5. Accessibility (obligation): a background tap is not keyboard/AT reachable on its own, so keep an EXPLICIT focusable, Enter/Space-activatable collapse control (e.g. the ticker row remains a Semantics button / a focusable caret) in addition to the background-click affordance. Announce expanded/collapsed + step count (as _ActivityCard does today). + +Touch points: +- New shared container primitive (extracted from _ActivityCard). +- conversation_view.dart: _ActivityCard adopts the primitive; background-toggle wiring; ensure child ConversationCards opaquely consume their bounds so they do not bubble taps to the holder. +- T-264 consumes the same primitive for the nested agent run. + +Edge cases: +- A holder whose child is itself collapsible (a tool card): tapping the child''s caret/body toggles the CHILD, never the holder. +- Text selection inside a child (ClideSelectionArea wraps the list): selection drags on child bodies must not be hijacked by the holder toggle — another reason the holder responds only to its own background, and only to taps (not drags). +- Empty / single-item holder. + +Tests: holder background tap toggles; tap on a child sub-card does NOT toggle the holder; copy button still copies (not swallowed); keyboard/AT path toggles via the explicit control; collapsed ticker + step count preserved. Add a golden for the holder container (collapsed + expanded). + +Refs: D-78. Provides the shared container consumed by T-264. Related: T-230 (activity card), T-262 / T-263 / T-265. Parent: T-267.', 'done', 'medium', NULL, NULL, 'D-78', '2026-06-07 08:41:13', '2026-06-08 09:04:12', NULL, '9da8b26d36d66bf1d26125519ec45872', 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 b7569f9c..73c8825f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Changed +- The folded **activity card** now reads as one **container that wraps its + sub-cards**, and you can collapse it by clicking anywhere on the holder's own + background — not just a top header that scrolls out of reach while a run + streams. Taps on a sub-card (and its copy button) still interact with that + card. A focusable caret keeps the control keyboard/AT reachable. (T-266) - 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 diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index f785dfc5..ca93d212 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -15,6 +15,7 @@ import 'dart:io'; import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; +import 'package:clide/builtin/claude/src/holder_card.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/kernel/src/facade.dart'; @@ -541,12 +542,12 @@ class _ConversationTurn extends StatelessWidget { } /// A folded run of meta items rendered as one collapsible activity card -/// (T-230). Collapsed (default): a one-line live ticker of the latest step + -/// a step count — re-grouped on every rebuild, so the ticker updates in place -/// as the run grows. Expanded: every folded step in order. Keyboard + screen -/// reader accessible: [ClideTappable] activates on Enter/Space, and the -/// Semantics announces the step count + expanded/collapsed state. -class _ActivityCard extends StatefulWidget { +/// (T-230), now through the shared [ClideHolderCard] container (T-266). +/// Collapsed (default): a one-line live ticker of the latest step + a step +/// count — re-grouped on every rebuild, so the ticker updates in place as the +/// run grows. Expanded: every folded step, wrapped in the holder frame whose +/// background toggles collapse. Stateless — the holder owns the expand state. +class _ActivityCard extends StatelessWidget { const _ActivityCard({ required this.items, required this.tokens, @@ -563,81 +564,23 @@ class _ActivityCard extends StatefulWidget { final Map resultByToolUseId; final Map> promptsByToolUseId; - @override - State<_ActivityCard> createState() => _ActivityCardState(); -} - -class _ActivityCardState extends State<_ActivityCard> { - bool _expanded = false; - @override Widget build(BuildContext context) { - final tokens = widget.tokens; - final count = widget.items.length; - final stepLabel = count == 1 ? '1 step' : '$count steps'; - - final header = ClideTappable( - onTap: () => setState(() => _expanded = !_expanded), - builder: (context, hovered, focused) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground, - border: Border.all(color: tokens.panelBorder), - borderRadius: BorderRadius.circular(4), - ), - child: Row( - children: [ - ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted), - const SizedBox(width: 8), - Expanded( - child: ClideText( - _expanded ? 'Activity' : _summarizeActivity(widget.items.last), - fontSize: clideFontCaption, - fontFamily: clideMonoFamily, - color: tokens.globalTextMuted, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - ClideText(stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted), - ], - ), - ), - ); - - return Semantics( - button: true, - expanded: _expanded, - label: 'Activity, $stepLabel, ${_expanded ? 'expanded' : 'collapsed'}', - excludeSemantics: true, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - header, - if (_expanded) - Padding( - padding: const EdgeInsets.only(left: 12, top: 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final item in widget.items) - _ConversationTurn( - item: item, - tokens: tokens, - toolUseOutcomes: widget.toolUseOutcomes, - toolUseById: widget.toolUseById, - resultByToolUseId: widget.resultByToolUseId, - promptsByToolUseId: widget.promptsByToolUseId, - ), - ], - ), - ), - ], - ), - ), + final count = items.length; + return ClideHolderCard( + collapsedSummary: _summarizeActivity(items.last), + stepLabel: count == 1 ? '1 step' : '$count steps', + children: [ + for (final item in items) + _ConversationTurn( + item: item, + tokens: tokens, + toolUseOutcomes: toolUseOutcomes, + toolUseById: toolUseById, + resultByToolUseId: resultByToolUseId, + promptsByToolUseId: promptsByToolUseId, + ), + ], ); } } diff --git a/lib/builtin/claude/src/holder_card.dart b/lib/builtin/claude/src/holder_card.dart new file mode 100644 index 00000000..8f841682 --- /dev/null +++ b/lib/builtin/claude/src/holder_card.dart @@ -0,0 +1,191 @@ +/// A collapsible container that holds a run of sub-cards as one unit (T-266). +/// +/// Collapsed (default): a one-line ticker — the latest step summary + a step +/// count — that toggles to expand. Expanded: a titled, framed container that +/// WRAPS the sub-cards; clicking the holder's own BACKGROUND (its padding, the +/// gaps between sub-cards, its gutter — anywhere a child doesn't cover) +/// collapses it. Taps on a child sub-card interact with that card, never the +/// holder, because each child opaquely consumes its full bounds. +/// +/// Why a background toggle (not just a top header): the conversation +/// tail-follows on every write, so a top-anchored collapse control is +/// unreachable while a run streams. A click on whatever background is currently +/// in view collapses the holder, ending that race. An explicit focusable caret +/// keeps the control keyboard/AT reachable (D-78). +/// +/// Shared primitive: the activity card (T-230) and the nested sub-agent run +/// (T-264) both render through this, so the container model is settled once. +library; + +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class ClideHolderCard extends StatefulWidget { + const ClideHolderCard({ + super.key, + required this.collapsedSummary, + required this.stepLabel, + required this.children, + this.title = 'Activity', + this.initiallyExpanded = false, + }); + + /// One-line gist of the latest step, shown in the collapsed ticker. + final String collapsedSummary; + + /// Count label, e.g. `3 steps` — shown in both the ticker and the header, + /// and announced for AT. + final String stepLabel; + + /// The sub-cards, shown wrapped when expanded. + final List children; + + /// Title shown in the expanded header (default `Activity`). + final String title; + + final bool initiallyExpanded; + + @override + State createState() => _ClideHolderCardState(); +} + +class _ClideHolderCardState extends State { + late bool _expanded = widget.initiallyExpanded; + final FocusNode _controlFocus = FocusNode(debugLabel: 'holder-control'); + + @override + void dispose() { + _controlFocus.dispose(); + super.dispose(); + } + + void _toggle() => setState(() => _expanded = !_expanded); + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + button: true, + expanded: _expanded, + label: '${widget.title}, ${widget.stepLabel}, ${_expanded ? 'expanded' : 'collapsed'}', + excludeSemantics: true, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens), + ), + ); + } + + /// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT. + Widget _tickerRow(SurfaceTokens tokens) => ClideTappable( + focusNode: _controlFocus, + onTap: _toggle, + tooltip: 'Expand', + builder: (context, hovered, focused) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground, + border: Border.all(color: tokens.panelBorder), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + ClideIcon(const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted), + const SizedBox(width: 8), + Expanded( + child: ClideText( + widget.collapsedSummary, + fontSize: clideFontCaption, + fontFamily: clideMonoFamily, + color: tokens.globalTextMuted, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted), + ], + ), + ), + ); + + /// Expanded: a framed container wrapping the sub-cards. The frame BACKGROUND + /// is a gesture target behind the children that only fires for hits the + /// children don't consume. + Widget _expandedFrame(SurfaceTokens tokens) => DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: tokens.panelBorder), + borderRadius: BorderRadius.circular(4), + ), + child: Stack( + children: [ + // Background toggle: behind the children, not a whole-card overlay, + // so child taps are never intercepted. Excluded from focus traversal + // — the header caret is the single keyboard stop. + Positioned.fill( + child: ExcludeFocus( + child: ClideTappable( + onTap: _toggle, + tooltip: 'Collapse', + builder: (_, __, ___) => const SizedBox.expand(), + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _headerRow(tokens), + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in widget.children) + // Each child opaquely consumes its full bounds so a body + // tap interacts with the card (or does nothing), never + // the holder background. Deeper controls (caret/copy) + // still win; only taps are absorbed, so selection drags + // pass through to the SelectionArea. + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: child, + ), + ], + ), + ), + ], + ), + ], + ), + ); + + /// The explicit, focusable collapse control. A background tap is not + /// keyboard/AT reachable on its own, so this keeps the control on the Tab + /// path and Enter/Space-activatable (D-78). + Widget _headerRow(SurfaceTokens tokens) => ClideTappable( + focusNode: _controlFocus, + onTap: _toggle, + tooltip: 'Collapse', + builder: (context, hovered, focused) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground, + border: Border(bottom: BorderSide(color: tokens.panelBorder)), + ), + child: Row( + children: [ + ClideIcon(const ChevronDownIcon(), size: 12, color: tokens.globalTextMuted), + const SizedBox(width: 8), + Expanded( + child: ClideText(widget.title, fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted), + ), + const SizedBox(width: 8), + ClideText(widget.stepLabel, fontSize: clideFontCaption, color: tokens.globalTextMuted), + ], + ), + ), + ); +} diff --git a/test/builtin/claude/holder_card_test.dart b/test/builtin/claude/holder_card_test.dart new file mode 100644 index 00000000..89a4cd60 --- /dev/null +++ b/test/builtin/claude/holder_card_test.dart @@ -0,0 +1,132 @@ +/// Widget tests for the shared [ClideHolderCard] container primitive (T-266): +/// collapsed ticker, background-as-toggle, child taps not hijacked, the copy +/// control still works, and the keyboard/AT collapse path. +library; + +import 'package:clide/builtin/claude/src/conversation_card.dart'; +import 'package:clide/builtin/claude/src/holder_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(); + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (_) async => null); + }); + tearDown(() { + TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + f.dispose(); + }); + + // A tight, positioned tree so the holder's Row/Expanded get a bounded width + // and a deterministic on-screen rect for coordinate hit-testing. + Future pump(WidgetTester tester, {List? children, String summary = 'latest step', bool expanded = false}) async { + await tester.pumpWidget(harness( + f, + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: ClideHolderCard( + collapsedSummary: summary, + stepLabel: '2 steps', + initiallyExpanded: expanded, + children: children ?? + const [ + ConversationCard( + variant: ConversationCardVariant.bordered, + accent: Color(0xFFFFFFFF), + label: 'step', + body: Text('child body', textDirection: TextDirection.ltr), + ), + ], + ), + ), + ), + )); + await tester.pump(); + } + + testWidgets('collapsed shows the ticker summary + step count; tap expands', (tester) async { + await pump(tester); + expect(find.text('latest step'), findsOneWidget); + expect(find.text('2 steps'), findsOneWidget); + expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget); + expect(find.text('child body'), findsNothing); // children hidden while collapsed + + await tester.tap(find.bySemanticsLabel('Activity, 2 steps, collapsed')); + await tester.pump(); + expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget); + expect(find.text('child body'), findsOneWidget); + }); + + testWidgets('a tap on the holder BACKGROUND (gutter) collapses it', (tester) async { + await pump(tester, expanded: true); + expect(find.text('child body'), findsOneWidget); // expanded + + // The left gutter (x = left+4) is holder background — children start at + // left+10 — and below the header, so this hits the background toggle. + final r = tester.getRect(find.byType(ClideHolderCard)); + await tester.tapAt(Offset(r.left + 4, r.center.dy)); + await tester.pump(); + expect(find.text('child body'), findsNothing); // collapsed + expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget); + }); + + testWidgets('a tap on a child sub-card does NOT toggle the holder', (tester) async { + await pump(tester, expanded: true); + expect(find.text('child body'), findsOneWidget); + + // Tapping the child's body must be absorbed by the child, not bubble to the + // holder background toggle. + await tester.tap(find.text('child body')); + await tester.pump(); + expect(find.text('child body'), findsOneWidget); // still expanded + expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget); + }); + + testWidgets('a child copy button still copies — not swallowed by the holder', (tester) async { + await pump(tester, expanded: true, children: const [ + ConversationCard( + variant: ConversationCardVariant.bordered, + accent: Color(0xFFFFFFFF), + label: 'step', + copyText: 'copied from a held card', + body: Text('child body', textDirection: TextDirection.ltr), + ), + ]); + + // Hover the child to reveal its copy action, then tap it. + 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(); + + await tester.tap(find.text('copy')); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + expect(f.services.clipboard.readAs(), 'copied from a held card'); + // And the holder stayed expanded (the copy tap wasn't a background toggle). + expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget); + }); + + testWidgets('the explicit control is keyboard-focusable and toggles on Activate (a11y)', (tester) async { + await pump(tester); // collapsed; the ticker row is the control + final focusWidget = tester.widget( + find.ancestor(of: find.text('latest step'), matching: find.byType(Focus)).first, + ); + focusWidget.focusNode!.requestFocus(); + await tester.pump(); + expect(focusWidget.focusNode!.hasFocus, isTrue); + + Actions.invoke(focusWidget.focusNode!.context!, const ActivateIntent()); + await tester.pump(); + expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget); + }); +} diff --git a/test/goldens/goldens/linux/holder_card.png b/test/goldens/goldens/linux/holder_card.png new file mode 100644 index 00000000..6c23a605 Binary files /dev/null and b/test/goldens/goldens/linux/holder_card.png differ diff --git a/test/goldens/holder_card_goldens_test.dart b/test/goldens/holder_card_goldens_test.dart new file mode 100644 index 00000000..eb7646dc --- /dev/null +++ b/test/goldens/holder_card_goldens_test.dart @@ -0,0 +1,66 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:clide/builtin/claude/src/conversation_card.dart'; +import 'package:clide/builtin/claude/src/holder_card.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()); + tearDown(() async => f.dispose()); + + goldenTest( + 'ClideHolderCard (T-266): collapsed ticker + expanded container of sub-cards', + fileName: 'holder_card', + builder: () => GoldenTestGroup( + columns: 1, + children: [ + GoldenTestScenario( + name: 'collapsed (ticker + step count)', + child: _wrap( + f, + const ClideHolderCard( + collapsedSummary: 'Bash ls -la', + stepLabel: '3 steps', + children: [], + ), + ), + ), + GoldenTestScenario( + name: 'expanded (framed container wrapping sub-cards)', + child: _wrap( + f, + const ClideHolderCard( + collapsedSummary: 'Bash ls -la', + stepLabel: '3 steps', + initiallyExpanded: true, + children: [ + ConversationCard( + variant: ConversationCardVariant.bordered, + accent: Color(0xFF4C9AFF), + label: 'Bash', + body: Text('ls -la', textDirection: TextDirection.ltr), + ), + ConversationCard( + variant: ConversationCardVariant.bordered, + accent: Color(0xFF4C9AFF), + label: 'Read', + body: Text('/lib/main.dart', textDirection: TextDirection.ltr), + ), + ], + ), + ), + ), + ], + ), + ); +} + +Widget _wrap(KernelFixture f, Widget child) => SizedBox( + width: 360, + child: harness(f, child), + );