diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index fe0b03b4..6791ebb7 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -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'', , ''--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; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 3f4f7217..4d02bff9 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -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 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..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'', , ''--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); diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9395d5..c28c9268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/lib/builtin/claude/src/conversation_card.dart b/lib/builtin/claude/src/conversation_card.dart index 4153b9a5..45b32e84 100644 --- a/lib/builtin/claude/src/conversation_card.dart +++ b/lib/builtin/claude/src/conversation_card.dart @@ -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 { 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 _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 { 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 { ), ] 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 _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; } diff --git a/test/builtin/claude/conversation_card_test.dart b/test/builtin/claude/conversation_card_test.dart index 9afdf58c..95fa3912 100644 --- a/test/builtin/claude/conversation_card_test.dart +++ b/test/builtin/claude/conversation_card_test.dart @@ -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(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(), '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( + 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.delayed(const Duration(milliseconds: 20))); + + expect(f.services.clipboard.readAs(), '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);