From 75f7d1ca1207e2c996c155d4c3f831674b8e36c0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 11:04:38 +0200 Subject: [PATCH] restyle activity card as a holder container; background-toggle collapse (T-266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts a shared ClideHolderCard primitive (consumed next by T-264) that renders a folded run as one framed container wrapping its sub-cards: - The whole holder background is the collapse toggle — a gesture target behind the children that only fires for hits the children don't consume. Each sub-card opaquely absorbs its own bounds, so a card tap (and its copy button) interacts with the card, never the holder; selection drags pass through. This ends the scroll race: while a run tail-follows, a click on whatever background is in view collapses it, no top header to reach. - A focusable caret keeps the control keyboard/AT reachable (D-78); the collapsed ticker + step count are preserved. - _ActivityCard becomes a thin stateless adopter of the primitive. Tests: ticker/expand, background-toggle, child-tap-not-hijacked, copy still works, keyboard Activate path; golden for collapsed + expanded. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 60 ++++++ CHANGELOG.md | 5 + lib/builtin/claude/src/conversation_view.dart | 101 ++------- lib/builtin/claude/src/holder_card.dart | 191 ++++++++++++++++++ test/builtin/claude/holder_card_test.dart | 132 ++++++++++++ test/goldens/goldens/linux/holder_card.png | Bin 0 -> 19589 bytes test/goldens/holder_card_goldens_test.dart | 66 ++++++ 8 files changed, 478 insertions(+), 79 deletions(-) create mode 100644 lib/builtin/claude/src/holder_card.dart create mode 100644 test/builtin/claude/holder_card_test.dart create mode 100644 test/goldens/goldens/linux/holder_card.png create mode 100644 test/goldens/holder_card_goldens_test.dart 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 0000000000000000000000000000000000000000..6c23a60563d8ef1ab62a82964f02a32695eddca8 GIT binary patch literal 19589 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 kI0nQI7~$jogtTljl<3q{uDw^I`y&Rpo 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), + );