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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user