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:
@@ -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;
|
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-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-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;
|
||||||
|
|||||||
@@ -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):
|
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);
|
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);
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- 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
|
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
|
into its **Agent/Task card**, collapsed by default. The prompt attaches to the
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'dart:io';
|
|||||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
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_card.dart';
|
||||||
import 'package:clide/builtin/claude/src/conversation_controller.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/prompt_card.dart';
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||||
import 'package:clide/kernel/src/facade.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
|
/// 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 +
|
/// (T-230), now through the shared [ClideHolderCard] container (T-266).
|
||||||
/// a step count — re-grouped on every rebuild, so the ticker updates in place
|
/// Collapsed (default): a one-line live ticker of the latest step + a step
|
||||||
/// as the run grows. Expanded: every folded step in order. Keyboard + screen
|
/// count — re-grouped on every rebuild, so the ticker updates in place as the
|
||||||
/// reader accessible: [ClideTappable] activates on Enter/Space, and the
|
/// run grows. Expanded: every folded step, wrapped in the holder frame whose
|
||||||
/// Semantics announces the step count + expanded/collapsed state.
|
/// background toggles collapse. Stateless — the holder owns the expand state.
|
||||||
class _ActivityCard extends StatefulWidget {
|
class _ActivityCard extends StatelessWidget {
|
||||||
const _ActivityCard({
|
const _ActivityCard({
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.tokens,
|
required this.tokens,
|
||||||
@@ -563,81 +564,23 @@ class _ActivityCard extends StatefulWidget {
|
|||||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||||
|
|
||||||
@override
|
|
||||||
State<_ActivityCard> createState() => _ActivityCardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ActivityCardState extends State<_ActivityCard> {
|
|
||||||
bool _expanded = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = widget.tokens;
|
final count = items.length;
|
||||||
final count = widget.items.length;
|
return ClideHolderCard(
|
||||||
final stepLabel = count == 1 ? '1 step' : '$count steps';
|
collapsedSummary: _summarizeActivity(items.last),
|
||||||
|
stepLabel: count == 1 ? '1 step' : '$count steps',
|
||||||
final header = ClideTappable(
|
children: [
|
||||||
onTap: () => setState(() => _expanded = !_expanded),
|
for (final item in items)
|
||||||
builder: (context, hovered, focused) => Container(
|
_ConversationTurn(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
item: item,
|
||||||
decoration: BoxDecoration(
|
tokens: tokens,
|
||||||
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
border: Border.all(color: tokens.panelBorder),
|
toolUseById: toolUseById,
|
||||||
borderRadius: BorderRadius.circular(4),
|
resultByToolUseId: resultByToolUseId,
|
||||||
),
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user