make conversation card actions keyboard-reachable

The copy button and custom message actions only rendered on hover, so
they were unreachable by keyboard or assistive tech. Keep them in the
tree always — revealed via opacity on hover OR focus — and route each
through ClideTappable (Tab traversal + Enter/Space activation) with a
Semantics button label and onTap so AT can discover and invoke them.
alwaysIncludeSemantics keeps them in the semantics tree while hidden.

T-174.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-30 23:09:57 +02:00
co-authored by Claude
parent c5e58733a2
commit 80401a3228
5 changed files with 269 additions and 35 deletions
@@ -2280,3 +2280,10 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-160', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 10:48:32', '2026-05-30 10:48:32', '2026-05-30 10:48:32', NULL, 'e410b6f78d0e4d96bc37f3a351d72767', 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-160', 'status', 'in_progress', 'done', NULL, '2026-05-30 11:05:56', '2026-05-30 11:05:56', '2026-05-30 11:05:56', NULL, '0024103be5bf0ab0653b9dbe03c52f71', 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-167', 'status', 'in_progress', 'done', NULL, '2026-05-30 11:05:56', '2026-05-30 11:05:56', '2026-05-30 11:05:56', NULL, 'fee8b67a0ad9c7c1c7ec7d9f75f42bfc', 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-168', 'status', 'in_progress', 'done', NULL, '2026-05-30 11:44:28', '2026-05-30 11:44:28', '2026-05-30 11:44:28', NULL, '152c34a28e7005386a4a6829d942e569', 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-172', 'description', 'Branch a conversation into a new managed session/pane, reusing the orchestrator''s spawn plumbing. ''Fork'' = spawn a managed session seeded from another session''s context via --resume + --fork-session (resume the source, but create a new session-id so the branch diverges without touching the original). Surface it as a slash action and a sidebar/roster button on a session. Acceptance: forking an active session opens a new pane continuing from its context, independent thereafter; the original is unaffected; tests for the fork argv + the new managed session appearing in the roster. Blocked by T-169 (orchestrator).', 'Branch a conversation into a new managed session/pane, reusing the orchestrator''s spawn plumbing. ''Fork'' = spawn a managed session seeded from another session''s context via --resume + --fork-session (resume the source, but create a new session-id so the branch diverges without touching the original). Surface it as a slash action and a sidebar/roster button on a session. Acceptance: forking an active session opens a new pane continuing from its context, independent thereafter; the original is unaffected; tests for the fork argv + the new managed session appearing in the roster. Blocked by T-169 (orchestrator).
---
Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live binary (claude --help: "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)"). Fork argv = [''--resume'', <sourceSessionId>, ''--fork-session''] (no --session-id; the new branch gets its own id from the init event). Add forkSourceSessionId to SpawnSpec (session_orchestrator.dart); build argv via a forkSessionArgs helper in session_naming.dart; add ''fork'' to kClideOwnedCommands + a case in claude_pane._send; add a fork button to _memberRow in claude_meta_sidebar.dart. SEQUENCE AFTER T-171 (shares _memberRow + orchestrator).', NULL, '2026-05-30 17:12:17', '2026-05-30 17:12:17', '2026-05-30 17:12:17', NULL, '901eb461070872a8dcb61f21546382d6', 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-171', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 17:12:46', '2026-05-30 17:12:46', '2026-05-30 17:12:46', NULL, '04314327ba504ef372a338ed737650a8', 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-174', 'status', 'backlog', 'in_progress', NULL, '2026-05-30 17:12:46', '2026-05-30 17:12:46', '2026-05-30 17:12:46', NULL, '940531017064b45bd962a8fe7369ec6d', 1) ON CONFLICT(hash) DO NOTHING;
+28
View File
@@ -2826,3 +2826,31 @@ Fix direction: give the in-pane context slot a flexible/bounded width in the sta
---
Refinement (2026-05-30, /whats-next): pulled into the T-167/T-168 batch and sequenced FIRST. T-168 adds live cost / permission-mode / context / rate-limit to this same in-pane status slot; the Flexible/marquee fix here must land before T-168 lengthens the line. After this fix, T-168 can safely add fields to the slot.', 'done', 'medium', NULL, NULL, NULL, '2026-05-24 09:13:42', '2026-05-30 11:05:56', NULL, 'c9c1801d8523ed0af1aff7da39393a0c', 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-184', 'task', 'T-164', 'Validate partial-message wire shape from --include-partial-messages against a live capture', 'T-168 follow-up. T-168 wired --include-partial-messages into the interactive stream-json session (stream_json_session.dart) and added a partial-streaming handler that ASSUMES claude emits incremental assistant events carrying partial:true sharing one message.id. That shape is UNVERIFIED: the 2.1.150 spike (docs/spikes/cc-stream-json-control-protocol-2.1.150.md) only documents non-partial assistant events (one per content block, no partial flag), and the flag''s --help says it ''only works with --print'' while clide runs the interactive control protocol (--input-format/--output-format stream-json, not --print). The handler degrades gracefully (unmatched shapes fall through to parseTranscriptChunk and are ignored — token streaming is inert but nothing breaks), and the T-168 fixtures are synthetic so they pass regardless of reality. Do: capture a real claude turn over the interactive stream-json transport with --include-partial-messages and record the actual partial event envelope (likely a stream_event content_block_delta, not assistant+partial:true). Then either (a) fix the handler in stream_json_session.dart::_onLine to match the real shape and add a fixture from the live capture, or (b) if the flag is a true no-op in the interactive (non-print) mode, drop --include-partial-messages from the argv and the dead partial-handler branch, and pin the finding in the spike doc. Either way, update the UNVERIFIED-WIRE-SHAPE comment in stream_json_session.dart and the spike. Acceptance: the partial path is validated against a live capture, the handler matches the real shape (or is removed), a fixture derived from the capture covers it, and the spike + code comment reflect the confirmed shape.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-30 11:42:53', '2026-05-30 11:42:53', NULL, '38dccbc403b8ac945bd2ad01d9ac1640', 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-168', 'task', 'T-164', 'P1: native rendering leveled up — typed event cards + streaming + live status', 'Exploit the structured stream the transcript-tail never cleanly gave. Per-tool cards from tool_use input (Edit/Write → diff, Bash → command+output, Read/Grep → file/query); pair each tool_result to its tool_use (with is_error) so diffs/failures render in place; stream assistant text + thinking blocks token-by-token via --include-partial-messages; surface live model / permission-mode / context-tokens / cost and rate_limit_event straight off the init + result events (retires the ClaudeConfig probe + status-bar guessing for the active pane); render structured errors as events. Acceptance: a session with edits/bash/reads shows the right card per tool with paired results; replies stream; the status line reflects live model/mode/context/cost from events; widget + fixture tests. Blocked by T-165.
---
Refinement (2026-05-30, /whats-next) five seam decisions resolved:
1. tool_use/result pairing: add a Map<String, AssistantToolUse> toolUseById index to ConversationController. The result card looks up its tool_use and renders the Edit/Write diff (reuse prompt_card''s _editBody style) or the is_error failure IN PLACE in the result card. Files: conversation_controller.dart (index), conversation_view.dart (_toolUse / _toolResult bodies they currently render indented JSON only and can''t see each other).
2. Per-tool card bodies: Edit/Write diff; Bash command+output; Read/Grep file/query. Selected by tool name off the paired tool_use input.
3. Partial-message streaming: pass --include-partial-messages in ClaudeStreamJsonProcess.start(); accumulate partials keyed by message.id in StreamJsonSession._onLine BEFORE emitting to the controller, so one item updates in place (no duplicate AssistantTextMessage items). Thinking blocks stream the same way. Do NOT add a new streaming item type in the controller.
4. Live cost/context (was an open gap, defaulted): extend SessionStatus (transcript_reader.dart) with cost + contextWindow fields; extract them from result events in stream_json_session.dart::_statusFromEvent (currently only handles system/init) total_cost_usd + modelUsage.<model>.contextWindow per the spike doc docs/spikes/cc-stream-json-control-protocol-2.1.150.md.
5. rate_limit_event + the focus status line: surface live model/mode/context/cost/rate-limit in the in-pane status slot. This DEPENDS ON T-160 (status-bar overflow / marquee) which is pulled into this batch and sequenced FIRST do not lengthen the status line until T-160''s Flexible/marquee fix lands. ClaudeConfig.ensureProbe() is retired only for the ACTIVE-pane status; the probe survives for slash-command typeahead.
Files an implementer touches: conversation_view.dart, conversation_controller.dart, transcript_reader.dart, stream_json_session.dart, claude_pane.dart, plus test/builtin/claude/*. Commit the in-flight prompt_card.dart change first (disjoint surface — permission-card Write-description dedup).
Sequencing within the batch: T-160 T-168.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:39', '2026-05-30 11:44:28', NULL, 'd2358f27340e318a3804a330dc1bc615', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-172', 'task', 'T-164', 'P2: fork-into-a-pane (--fork-session)', 'Branch a conversation into a new managed session/pane, reusing the orchestrator''s spawn plumbing. ''Fork'' = spawn a managed session seeded from another session''s context via --resume + --fork-session (resume the source, but create a new session-id so the branch diverges without touching the original). Surface it as a slash action and a sidebar/roster button on a session. Acceptance: forking an active session opens a new pane continuing from its context, independent thereafter; the original is unaffected; tests for the fork argv + the new managed session appearing in the roster. Blocked by T-169 (orchestrator).
---
Refinement (2026-05-30, /whats-next): --fork-session CONFIRMED against the live binary (claude --help: "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)"). Fork argv = [''--resume'', <sourceSessionId>, ''--fork-session''] (no --session-id; the new branch gets its own id from the init event). Add forkSourceSessionId to SpawnSpec (session_orchestrator.dart); build argv via a forkSessionArgs helper in session_naming.dart; add ''fork'' to kClideOwnedCommands + a case in claude_pane._send; add a fork button to _memberRow in claude_meta_sidebar.dart. SEQUENCE AFTER T-171 (shares _memberRow + orchestrator).', 'backlog', 'low', NULL, NULL, 'D-77', '2026-05-24 16:27:16', '2026-05-30 17:12:17', NULL, '6cc2eaeeeeb21fec55cc179fd8faa6af', 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-171', 'task', 'T-164', 'P2: sidebar cockpit — task list + messages + roster controls', 'Evolve the Claude meta sidebar (T-141/T-157) from a read-only roster into the CONTROL surface for clide-managed agents. Because clide owns the broker, the task list and inter-agent messages are local data — so the sidebar both shows them live and lets the user ACT: spawn / show / hide / mute an agent, reassign a task, inject or redirect a message. Roster rows keep the live per-member status (T-157) but gain controls. Acceptance: from the sidebar the user can spawn/show/hide an agent, watch the shared task list update, see the message flow, and intervene (reassign/inject); widget tests for the controls wired to the orchestrator + broker. Blocked by T-169 (orchestrator) and T-170 (broker).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:09', '2026-05-30 17:12:46', NULL, '0e369ad03149a1f4cef4ec12bbc05d0a', 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-174', 'task', 'T-164', 'ConversationCard actions: keyboard-focusable + always-reachable (not hover-only)', 'Follow-up split out of T-173. The ConversationCard action bar (copy button + custom MessageActions) currently renders only while the card is hovered (lib/builtin/claude/src/conversation_card.dart, _header: ''if (_hover) ..._actions''). The collapse/expand caret is always visible, but the action buttons are not keyboard/AT reachable when not hovering.
Make the actions always reachable per T-173''s acceptance: keep them in the tree always (reveal visually on hover OR focus via opacity), add Semantics onTap so assistive tech can activate them, and wire keyboard focus traversal + Enter/Space activation through clide''s keyboard-operability framework (the _tap helper uses a bare GestureDetector today, which is mouse/AT-tap only, not focus-traversable). Applies to both the action buttons and the caret.
Acceptance: copy + custom actions are reachable and activatable by keyboard alone (Tab to focus, Enter/Space to invoke) with no mouse; Semantics button labels present; widget test drives a copy via focus+activation (no hover); a11y gate green.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 17:11:17', '2026-05-30 17:12:46', NULL, '5294925aae67ed738dd8f2096469f56c', 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);
+3
View File
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
- Claude conversation card actions (copy + custom) are now keyboard-focusable
and always reachable — revealed on hover or focus, activatable by Tab +
Enter/Space, with Semantics labels for assistive tech (T-174).
- Status bar no longer overflows when the focused-pane context line is long
— the in-pane slot now takes a flexible share of the bar and marquee-scrolls
within it instead of pushing the row past its width.
+129 -32
View File
@@ -2,11 +2,16 @@
///
/// One primitive with three [ConversationCardVariant]s (the stripe, bordered,
/// and bare looks the view used to hand-roll), plus chrome wired in once for
/// all message types: a hover-revealed copy button, an always-visible
/// all message types: a hover/focus-revealed copy button, an always-visible
/// collapse/expand caret for collapsible turns, and an extensible
/// [MessageAction] list. Decoupled from `ConversationItem` — the view maps
/// each item to (variant, accent, label, body, copyText, actions), so future
/// typed cards (T-168) reuse this chrome with a different body.
///
/// T-174: action buttons are always in the widget tree (keyboard/AT reachable);
/// they are revealed visually only while the card is hovered OR any action
/// holds keyboard focus. The caret and each action use [ClideTappable] so Tab
/// traversal + Enter/Space activation work without hovering.
library;
import 'dart:async';
@@ -72,6 +77,66 @@ class _ConversationCardState extends State<ConversationCard> {
bool _hover = false;
late bool _collapsed = widget.collapsible && widget.collapsedByDefault;
// Focus nodes for the action buttons (copy + custom), managed so that
// the Opacity covering the action bar lifts when any action is focused.
// The caret uses its own always-present node (never hidden by Opacity).
final FocusNode _caretFocus = FocusNode(debugLabel: 'card-caret');
List<FocusNode> _actionFocusNodes = [];
int _focusedActionCount = 0;
bool get _anyActionFocused => _focusedActionCount > 0;
/// Rebuilds the action-button focus-node list to match the current set of
/// actions (copyText present or not, plus widget.actions count).
void _syncActionFocusNodes() {
final needed = (widget.copyText != null ? 1 : 0) + widget.actions.length;
if (needed == _actionFocusNodes.length) return;
// Dispose the excess or add new ones.
if (needed < _actionFocusNodes.length) {
for (var i = needed; i < _actionFocusNodes.length; i++) {
_actionFocusNodes[i].removeListener(_onActionFocusChange);
_actionFocusNodes[i].dispose();
}
_actionFocusNodes = _actionFocusNodes.sublist(0, needed);
} else {
for (var i = _actionFocusNodes.length; i < needed; i++) {
final node = FocusNode(debugLabel: 'card-action-$i');
node.addListener(_onActionFocusChange);
_actionFocusNodes.add(node);
}
}
}
void _onActionFocusChange() {
final focused = _actionFocusNodes.where((n) => n.hasFocus).length;
if (focused != _focusedActionCount) {
setState(() => _focusedActionCount = focused);
}
}
@override
void initState() {
super.initState();
_syncActionFocusNodes();
}
@override
void didUpdateWidget(ConversationCard old) {
super.didUpdateWidget(old);
_syncActionFocusNodes();
}
@override
void dispose() {
_caretFocus.dispose();
for (final n in _actionFocusNodes) {
n.removeListener(_onActionFocusChange);
n.dispose();
}
super.dispose();
}
void _copy() {
final text = widget.copyText;
if (text != null) unawaited(ClideKernel.of(context).clipboard.writePlain(text));
@@ -134,6 +199,9 @@ class _ConversationCardState extends State<ConversationCard> {
Widget _header(SurfaceTokens tokens) {
final summary = widget.collapsedSummary;
// Action visibility: shown when hovered OR when any action button has
// keyboard focus. Always in the tree so Tab/AT can reach them.
final showActions = _hover || _anyActionFocused;
return Row(
children: [
if (widget.collapsible) _caret(tokens),
@@ -147,51 +215,80 @@ class _ConversationCardState extends State<ConversationCard> {
),
] else
const Spacer(),
// Hover-revealed actions. (Always-reachable keyboard a11y for these is
// a follow-up detail; the collapse caret above is always visible.)
if (_hover) ..._actions(tokens),
// Actions are always in the tree (keyboard/AT always reachable).
// Opacity reveals them on hover or keyboard focus; opacity-0 keeps
// them layout-present but visually hidden so they don't distract.
// alwaysIncludeSemantics keeps them in the semantics tree even at
// opacity 0 (RenderOpacity drops semantics at 0 by default) so AT can
// still discover and activate them without hovering.
Opacity(
opacity: showActions ? 1.0 : 0.0,
alwaysIncludeSemantics: true,
child: Row(children: _actions(tokens)),
),
],
);
}
Widget _caret(SurfaceTokens tokens) {
return _tap(
label: _collapsed ? 'Expand' : 'Collapse',
final label = _collapsed ? 'Expand' : 'Collapse';
return Semantics(
button: true,
label: label,
excludeSemantics: true,
onTap: () => setState(() => _collapsed = !_collapsed),
child: Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(
_collapsed ? PhosphorIcons.caretRight : PhosphorIcons.caretDown,
size: 12,
color: tokens.globalTextMuted,
child: ClideTappable(
focusNode: _caretFocus,
tooltip: label,
onTap: () => setState(() => _collapsed = !_collapsed),
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(right: 6),
child: ClideIcon(
_collapsed ? PhosphorIcons.caretRight : PhosphorIcons.caretDown,
size: 12,
color: tokens.globalTextMuted,
),
),
),
);
}
List<Widget> _actions(SurfaceTokens tokens) {
Widget btn(String label, VoidCallback onTap) => _tap(
label: label,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
);
final items = <_ActionItem>[];
if (widget.copyText != null) items.add(_ActionItem('copy', _copy));
for (final a in widget.actions) {
items.add(_ActionItem(a.label, a.onInvoke));
}
return [
if (widget.copyText != null) btn('copy', _copy),
for (final a in widget.actions) btn(a.label, a.onInvoke),
for (var i = 0; i < items.length; i++)
Semantics(
button: true,
label: items[i].label,
excludeSemantics: true,
onTap: items[i].onTap,
child: ClideTappable(
focusNode: _actionFocusNodes[i],
tooltip: items[i].label,
onTap: items[i].onTap,
builder: (_, hovered, pressed) => Padding(
padding: const EdgeInsets.only(left: 10),
child: ClideText(
items[i].label,
fontSize: clideFontMeta,
color: tokens.globalTextMuted,
fontFamily: clideMonoFamily,
),
),
),
),
];
}
}
Widget _tap({required String label, required VoidCallback onTap, required Widget child}) {
return Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: MouseRegion(cursor: SystemMouseCursors.click, child: child),
),
);
}
/// Internal pairing of an action label and its callback.
class _ActionItem {
_ActionItem(this.label, this.onTap);
final String label;
final VoidCallback onTap;
}
+102 -3
View File
@@ -29,6 +29,32 @@ void main() {
await tester.pump();
}
// Returns the Opacity wrapping the action bar (the Opacity that reveals/hides
// the action buttons on hover or focus).
Opacity actionBarOpacity(WidgetTester tester) {
// The action bar Opacity is a direct child of the header Row — pick the
// first Opacity that is an ancestor of the 'copy' Semantics (or any action
// label), which is the one whose value toggles on hover/focus.
return tester.widgetList<Opacity>(find.byType(Opacity)).first;
}
testWidgets('copy button is always in the tree (Opacity 0 before hover)', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
),
));
await tester.pump();
// The text widget is in the tree (always), but the Opacity hides it.
expect(find.text('copy'), findsOneWidget);
expect(actionBarOpacity(tester).opacity, 0.0);
});
testWidgets('copy button (on hover) writes the copyText to the clipboard', (tester) async {
await tester.pumpWidget(harness(
f,
@@ -40,10 +66,9 @@ void main() {
),
));
await tester.pump();
expect(find.text('copy'), findsNothing); // hidden until hover
await hoverCard(tester);
expect(find.text('copy'), findsOneWidget);
expect(actionBarOpacity(tester).opacity, 1.0);
await tester.tap(find.text('copy'));
// writePlain awaits the clipboard channel; let the real async settle.
@@ -51,6 +76,63 @@ void main() {
expect(f.services.clipboard.readAs<String>(), 'the raw message');
});
testWidgets('copy button: keyboard focus + ActivateIntent writes to clipboard without hovering', (tester) async {
// T-174: actions must be keyboard-reachable even when the card is not hovered.
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'keyboard written',
body: Text('keyboard written', textDirection: TextDirection.ltr),
),
));
await tester.pump();
// Locate the ClideTappable whose FocusNode we want: the one wrapping the
// 'copy' label. We drive focus programmatically via the node embedded in
// the widget — find the Focus descendent nearest to the 'copy' text.
final copyTextFinder = find.text('copy');
expect(copyTextFinder, findsOneWidget);
// Walk up to find the Focus that ClideTappable installed, then request focus.
final focusWidget = tester.widget<Focus>(
find.ancestor(of: copyTextFinder, matching: find.byType(Focus)).first,
);
focusWidget.focusNode!.requestFocus();
await tester.pump(); // focus resolves; the focus listener calls setState
await tester.pump(); // the scheduled rebuild paints the lifted opacity
expect(focusWidget.focusNode!.hasFocus, isTrue, reason: 'copy action should accept keyboard focus');
// With focus, the action bar opacity should lift to 1.0.
expect(actionBarOpacity(tester).opacity, 1.0);
// Invoke via ActivateIntent (what Enter/Space dispatches from the keymap).
Actions.invoke(focusWidget.focusNode!.context!, const ActivateIntent());
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
expect(f.services.clipboard.readAs<String>(), 'keyboard written');
});
testWidgets('copy button carries a Semantics button label', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'msg',
body: Text('msg', textDirection: TextDirection.ltr),
),
));
await tester.pump();
// Semantics label 'copy' must be present even before hover, so AT can
// discover the button without the user mousing over first.
expect(find.bySemanticsLabel('copy'), findsOneWidget);
handle.dispose();
});
testWidgets('collapsible card hides its body until expanded', (tester) async {
await tester.pumpWidget(harness(
f,
@@ -87,6 +169,23 @@ void main() {
expect(find.bySemanticsLabel('Collapse'), findsNothing);
});
testWidgets('custom actions are always in the tree (Opacity 0 before hover)', (tester) async {
await tester.pumpWidget(harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () {})],
),
));
await tester.pump();
// 'fork' is in the tree (always), but the Opacity hides it.
expect(find.text('fork'), findsOneWidget);
expect(actionBarOpacity(tester).opacity, 0.0);
});
testWidgets('custom actions appear on hover and invoke', (tester) async {
var forked = false;
await tester.pumpWidget(harness(
@@ -99,9 +198,9 @@ void main() {
),
));
await tester.pump();
expect(find.text('fork'), findsNothing);
await hoverCard(tester);
expect(actionBarOpacity(tester).opacity, 1.0);
await tester.tap(find.text('fork'));
await tester.pump();
expect(forked, isTrue);