restyle activity card as a holder container; background-toggle collapse (T-266)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:04:38 +02:00
co-authored by Claude Opus 4.8
parent a7a7b04a05
commit 75f7d1ca12
8 changed files with 478 additions and 79 deletions
@@ -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;
+60
View File
@@ -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);
+5
View File
@@ -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
+22 -79
View File
@@ -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<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> 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,
),
],
);
}
}
+191
View File
@@ -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<Widget> children;
/// Title shown in the expanded header (default `Activity`).
final String title;
final bool initiallyExpanded;
@override
State<ClideHolderCard> createState() => _ClideHolderCardState();
}
class _ClideHolderCardState extends State<ClideHolderCard> {
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),
],
),
),
);
}
+132
View File
@@ -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<void> pump(WidgetTester tester, {List<Widget>? 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<void>.delayed(const Duration(milliseconds: 20)));
expect(f.services.clipboard.readAs<String>(), '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<Focus>(
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);
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -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),
);