From 52418768717e50f30c1485e9f7f837e5564e6847 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 8 Jun 2026 16:13:01 +0200 Subject: [PATCH] clickable T/D/Q/R cross-refs in the Claude conversation (T-279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linkify bare ticket/governance refs (T-281, D-77, Q-5, R-2) in rendered conversation messages so clicking one opens the record in its context-pane reader — T- in the tickets reader, D/Q/R in the decisions reader — reusing the existing `selection` MessageBus addressing (the same path clide ui open and the panels use; D-6 parity already satisfied by `clide ui open`). ClideMarkdown now linkifies bare refs in running text (paragraphs, lists, headings, bold/italic), not just record-shaped markdown links. Matching is word-boundary anchored so "T-shirt" (no digits) and "PT-281" (mid-word) stay literal; `code` spans and `pre` blocks render verbatim and never reach the linkifier, so refs inside code stay plain. The clickable span is shared between bare refs and record-shaped links so both look and behave alike. Tests: ClideMarkdown linkifier cases (bare T/D/Q/R tap fires onRecordTap, T-shirt + inline-code refs stay plain, no-callback stays plain); conversation view integration (clicking a bare ref publishes the reader-open selection to the tickets/decisions reader). Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 6 ++ .pql/changelog/tickets/2026-06.sql | 71 +++++++++++++++++ CHANGELOG.md | 6 ++ lib/builtin/claude/src/conversation_view.dart | 13 +++- lib/widgets/src/clide_markdown.dart | 77 +++++++++++++------ .../claude/conversation_view_test.dart | 22 ++++++ test/widgets/more_widgets_test.dart | 54 +++++++++++++ 7 files changed, 225 insertions(+), 24 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 011bed67..1c4159f8 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -865,3 +865,9 @@ Acceptance: Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257. UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.', NULL, '2026-06-08 13:44:57', '2026-06-08 13:44:57', '2026-06-08 13:44:57', NULL, 'd8eb4299c87c4111a5bb904846e39655', 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-282', 'status', 'backlog', 'ready', NULL, '2026-06-08 13:48:31', '2026-06-08 13:48:31', '2026-06-08 13:48:31', NULL, 'fe941aceb2e27ca50d1be47bbc33dd8d', 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-279', 'status', 'backlog', 'ready', NULL, '2026-06-08 13:48:42', '2026-06-08 13:48:42', '2026-06-08 13:48:42', NULL, '705850ce44bbd34f3b1e441ef00891cc', 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-282', 'status', 'ready', 'in_progress', NULL, '2026-06-08 13:49:32', '2026-06-08 13:49:32', '2026-06-08 13:49:32', NULL, '1b49ae9cbea9a98aa6eb93ecd6329436', 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-279', 'status', 'ready', 'in_progress', NULL, '2026-06-08 13:49:32', '2026-06-08 13:49:32', '2026-06-08 13:49:32', NULL, 'e6593b168ebb31f66ca19fe3e36557cb', 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-275', 'status', 'backlog', 'ready', NULL, '2026-06-08 14:12:26', '2026-06-08 14:12:26', '2026-06-08 14:12:26', NULL, 'a6cea52d7e3e1d0881e6330f1d9799bd', 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-279', 'status', 'in_progress', 'done', NULL, '2026-06-08 14:12:46', '2026-06-08 14:12:46', '2026-06-08 14:12:46', NULL, '86c50eb88cf56b7e9e9914dcab61b9c3', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index d969d829..040a7884 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2118,3 +2118,74 @@ Acceptance: Cross-refs: T-274 (resumed-session status bar empty), T-167/T-185 (resume/fork session id capture), D-77, claude_pane.dart:279/300-308, session_orchestrator.dart:240/257. UPDATE 2026-06-08: the active hang did NOT reproduce — clide is running fine inside the 31b214bd primary session (this very session resumes cleanly). So the original break was a one-off (likely the single corrupted transcript), not a live resume bug. This ticket stands as defensive hardening only: the two gaps (no init-event timeout/fallback; resume keyed off file-exists not content) are real but latent — they''d only bite again if a resume genuinely stalls or a metadata-only transcript appears. Lowering to low priority.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-06-08 13:39:51', '2026-06-08 13:44:57', NULL, 'cf9aeb9f079ffbc01de1f46b779226d8', 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-282', 'bug', 'T-276', 'Collapsed conversation card has inconsistent bottom padding', 'The collapsed Agent (tool-call) card in the Claude conversation view has different padding beneath it than the spacing around all other cards — see the gap flagged below the `> Agent` disclosure in the conversation. Make the collapsed card''s bottom padding match the standard inter-card spacing used everywhere else. + +Card layout lives in lib/builtin/claude/src/conversation_card.dart: the outer wrapper applies EdgeInsets.only(bottom: 14) (line ~188) and the stripe variant adds inner EdgeInsets.fromLTRB(12, 8, 12, 8) (line ~210). When collapsed, only the header renders, so the combined bottom spacing (inner 8 + outer 14) reads differently from expanded cards. Reconcile so the visible gap below a collapsed card equals the gap below other cards.', 'ready', 'low', NULL, NULL, NULL, '2026-06-08 13:39:15', '2026-06-08 13:48:31', NULL, '90037351c352c6d19681c904f4fcbaea', 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-279', 'story', 'T-276', 'Clickable T/D/Q/R cross-refs in the Claude conversation view → open in context-pane readers', 'In the Claude conversation view, linkify governance/ticket references so they''re clickable and open the matching record in the correct context-pane reader. + +Patterns to detect in rendered message text: T-NNN (pql tickets), D-NNN (decisions), Q-NNN (open questions), R-NNN (rejected alternatives). + +Behaviour: +- Render each match as an inline link (ui-design tokens, link affordance consistent with other conversation links; do not linkify inside code spans/blocks). +- On click, open the record in the proper context-pane reader: + - T-NNN -> tickets reader/panel (the ticket detail). + - D-NNN / Q-NNN / R-NNN -> decisions reader, scrolled to the D/Q/R record under governance/decisions, /questions, /rejected. +- Reuse the existing reader-open plumbing rather than inventing new routing: ReaderNav / the ui.open verb / MessageBus reader addressing (T-186, T-231, T-233) and the decisions + tickets panels. +- D-6 parity: opening a record by id should already (or also) be reachable via a clide verb. + +Scope notes: +- Lives in the conversation rendering path (lib/builtin/claude/src/conversation_view.dart + conversation_card.dart + the markdown/text renderer it uses). Relates to the conversation-rendering epic T-267 — could move there if preferred. +- Resolve id -> source: tickets via the pql wrapper (lib/src/pql), governance records via the decisions index/reader. +- Handle unknown/missing ids gracefully (render as plain text or a dead-link tooltip, no crash). + +Tests: linkifier unit tests (matches T/D/Q/R, ignores code spans, ignores bare words like ''T-shirt''); a widget/integration test that clicking a ref dispatches the reader-open.', 'ready', 'medium', NULL, NULL, 'D-6', '2026-06-08 10:21:20', '2026-06-08 13:48:42', NULL, '7728659c72d9cf304006b21a649bac00', 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-279', 'story', 'T-276', 'Clickable T/D/Q/R cross-refs in the Claude conversation view → open in context-pane readers', 'In the Claude conversation view, linkify governance/ticket references so they''re clickable and open the matching record in the correct context-pane reader. + +Patterns to detect in rendered message text: T-NNN (pql tickets), D-NNN (decisions), Q-NNN (open questions), R-NNN (rejected alternatives). + +Behaviour: +- Render each match as an inline link (ui-design tokens, link affordance consistent with other conversation links; do not linkify inside code spans/blocks). +- On click, open the record in the proper context-pane reader: + - T-NNN -> tickets reader/panel (the ticket detail). + - D-NNN / Q-NNN / R-NNN -> decisions reader, scrolled to the D/Q/R record under governance/decisions, /questions, /rejected. +- Reuse the existing reader-open plumbing rather than inventing new routing: ReaderNav / the ui.open verb / MessageBus reader addressing (T-186, T-231, T-233) and the decisions + tickets panels. +- D-6 parity: opening a record by id should already (or also) be reachable via a clide verb. + +Scope notes: +- Lives in the conversation rendering path (lib/builtin/claude/src/conversation_view.dart + conversation_card.dart + the markdown/text renderer it uses). Relates to the conversation-rendering epic T-267 — could move there if preferred. +- Resolve id -> source: tickets via the pql wrapper (lib/src/pql), governance records via the decisions index/reader. +- Handle unknown/missing ids gracefully (render as plain text or a dead-link tooltip, no crash). + +Tests: linkifier unit tests (matches T/D/Q/R, ignores code spans, ignores bare words like ''T-shirt''); a widget/integration test that clicking a ref dispatches the reader-open.', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-08 10:21:20', '2026-06-08 13:49:32', NULL, '3f8139821de7ab3bbfd63e6aa89dd2bd', 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-282', 'bug', 'T-276', 'Collapsed conversation card has inconsistent bottom padding', 'The collapsed Agent (tool-call) card in the Claude conversation view has different padding beneath it than the spacing around all other cards — see the gap flagged below the `> Agent` disclosure in the conversation. Make the collapsed card''s bottom padding match the standard inter-card spacing used everywhere else. + +Card layout lives in lib/builtin/claude/src/conversation_card.dart: the outer wrapper applies EdgeInsets.only(bottom: 14) (line ~188) and the stripe variant adds inner EdgeInsets.fromLTRB(12, 8, 12, 8) (line ~210). When collapsed, only the header renders, so the combined bottom spacing (inner 8 + outer 14) reads differently from expanded cards. Reconcile so the visible gap below a collapsed card equals the gap below other cards.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-08 13:39:15', '2026-06-08 13:49:32', NULL, 'b68bcfb7ec333fba8aa9e3f995e5dad5', 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-275', 'task', 'T-276', 'Frame0 design: permission-mode dropdown beside the Claude composer', 'Produce a Frame0 wireframe for a persistent permission-mode control next to the Claude conversation composer, then use it to drive implementation. + +Goal: surface the current permission mode (default · accept-edits · plan; bypass behind the cockpit guard) AT ALL TIMES, not only in the bottom status bar. Move/duplicate the mode affordance to an icon button sitting to the RIGHT of the composer text box (ClaudeComposer), showing the current mode via icon (+ short label), and opening a dropdown/popover menu to switch mode on click. + +Design to wireframe (frame0-wireframe skill): +- Composer row with the mode icon-button anchored at its right edge (trailing), aligned with the send/interrupt control; show resting state for each mode (distinct icon/color per mode) and the open-dropdown state listing selectable modes with the active one marked. +- Account for the interaction zone (D-78): when a permission/AskUserQuestion prompt replaces the composer, define what happens to the mode button (hidden? disabled? persists above?). +- Keep it consistent with the existing status-bar mode badge (T-226: click or Ctrl/Cmd+M cycles the safe trio) — decide whether the status-bar badge stays, is removed, or mirrors the new control. +- Token/geometry per the ui-design skill (no hardcoded hex; trailing-control geometry like the composer''s existing buttons). + +Deliverable: a Frame0 wireframe (resting + open states, per-mode variants) exported, plus a short note on the chosen prompt-mode interaction, ready to implement. Relevant code: lib/builtin/claude/src/claude_composer.dart, claude_pane.dart (_ModeBadge / _statusWidget / _cycleMode), claude_status.dart (nextSafePermissionMode / permissionModeLabel).', 'ready', 'medium', NULL, NULL, 'D-78', '2026-06-08 07:28:34', '2026-06-08 14:12:26', NULL, 'e486c40ba7aa4f357c463fee89632233', 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-279', 'story', 'T-276', 'Clickable T/D/Q/R cross-refs in the Claude conversation view → open in context-pane readers', 'In the Claude conversation view, linkify governance/ticket references so they''re clickable and open the matching record in the correct context-pane reader. + +Patterns to detect in rendered message text: T-NNN (pql tickets), D-NNN (decisions), Q-NNN (open questions), R-NNN (rejected alternatives). + +Behaviour: +- Render each match as an inline link (ui-design tokens, link affordance consistent with other conversation links; do not linkify inside code spans/blocks). +- On click, open the record in the proper context-pane reader: + - T-NNN -> tickets reader/panel (the ticket detail). + - D-NNN / Q-NNN / R-NNN -> decisions reader, scrolled to the D/Q/R record under governance/decisions, /questions, /rejected. +- Reuse the existing reader-open plumbing rather than inventing new routing: ReaderNav / the ui.open verb / MessageBus reader addressing (T-186, T-231, T-233) and the decisions + tickets panels. +- D-6 parity: opening a record by id should already (or also) be reachable via a clide verb. + +Scope notes: +- Lives in the conversation rendering path (lib/builtin/claude/src/conversation_view.dart + conversation_card.dart + the markdown/text renderer it uses). Relates to the conversation-rendering epic T-267 — could move there if preferred. +- Resolve id -> source: tickets via the pql wrapper (lib/src/pql), governance records via the decisions index/reader. +- Handle unknown/missing ids gracefully (render as plain text or a dead-link tooltip, no crash). + +Tests: linkifier unit tests (matches T/D/Q/R, ignores code spans, ignores bare words like ''T-shirt''); a widget/integration test that clicking a ref dispatches the reader-open.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-08 10:21:20', '2026-06-08 14:12:46', NULL, 'e9aba50ab6a83dd280b3e4a375782dab', 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 59c8a5cf..0d174bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Clickable T/D/Q/R cross-refs in the Claude conversation.** Bare ticket and + governance references (`T-281`, `D-77`, `Q-5`, `R-2`) in rendered messages are + now links: clicking one opens the record in its context-pane reader — `T-` + in the tickets reader, `D`/`Q`/`R` in the decisions reader — reusing the same + `selection` addressing as `clide ui open` (D-6 parity). Refs inside code spans + and code blocks stay literal, and `T-shirt`-style words are left alone. (T-279) - A Zed-style **application menu bar** in the hat — **File / View / Help** menus built from custom widgets (no native menu, D-7), populated from the command registry with inline keybindings. Full keyboard nav (`Alt`+mnemonic, diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index b3240440..6603d819 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -298,6 +298,15 @@ const claudeAccent = Color(0xFFD97757); /// `Task`; the Agent SDK surface uses `Agent` — accept both (T-263). bool _isAgentTool(String name) => name == 'Task' || name == 'Agent'; +/// Open a governance/ticket record clicked in the conversation (T-279) in its +/// context-pane reader, reusing the existing `selection` MessageBus addressing +/// (the same path `clide ui open` and the panels use, T-231/T-233): T-NNN → the +/// tickets reader, D/Q/R-NNN → the decisions reader. +void _openRecord(BuildContext context, String id) { + final publisher = id.startsWith('T-') ? 'builtin.tickets' : 'builtin.decisions'; + ClideKernel.of(context).messages.publish(publisher, 'selection', {'id': id}); +} + /// One conversation item, rendered by kind. class _ConversationTurn extends StatelessWidget { const _ConversationTurn({ @@ -352,7 +361,7 @@ class _ConversationTurn extends StatelessWidget { accent: tokens.globalFocus, label: 'you', copyText: i.text, - body: ClideMarkdown(i.text), + body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id)), ), // Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the // agent with a muted accent, never the coral "claude" brand (T-265). The @@ -361,7 +370,7 @@ class _ConversationTurn extends StatelessWidget { accent: i.isSidechain ? tokens.globalTextMuted : claudeAccent, label: i.isSidechain ? 'agent' : 'claude', copyText: i.text, - body: ClideMarkdown(i.text), + body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id)), ), AssistantThinkingMessage() => ConversationCard( variant: ConversationCardVariant.bare, diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index f32ae264..550fbf3d 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -15,8 +15,18 @@ class ClideMarkdown extends StatelessWidget { static const double _fontSize = 16; static const double _lineHeight = clideLineHeight; + + /// A whole string that is exactly a record id — used for record-shaped + /// markdown links (`[T-281](…)`). static final _recordPattern = RegExp(r'^[DQRT]-\d+$'); + /// A bare governance/ticket ref inside running text (T-279): T-281, D-77, + /// Q-5, R-2. Anchored on word boundaries so "T-shirt" (no digits) and + /// "PT-281" (mid-word) stay literal. Only applied to rendered text — `code` + /// spans and `pre` blocks render verbatim and never reach the linkifier, so + /// refs inside code stay plain. + static final _bareRecordPattern = RegExp(r'\b[DQRT]-\d+\b'); + final String source; final RecordTapCallback? onRecordTap; @@ -60,7 +70,7 @@ class ClideMarkdown extends StatelessWidget { final spans = []; for (final n in inlineRun) { if (n is md.Text) { - spans.add(TextSpan(text: _unescapeHtml(n.text))); + spans.addAll(_linkifyText(_unescapeHtml(n.text), tokens, onRecordTap)); } else if (n is md.Element) { spans.add(_inlineElementSpan(n, tokens, onRecordTap)); } @@ -246,7 +256,7 @@ class ClideMarkdown extends StatelessWidget { final children = []; for (final child in el.children ?? const []) { if (child is md.Text) { - children.add(TextSpan(text: _unescapeHtml(child.text))); + children.addAll(_linkifyText(_unescapeHtml(child.text), tokens, onRecordTap)); } else if (child is md.Element) { children.add(_inlineElementSpan(child, tokens, onRecordTap)); } @@ -271,7 +281,7 @@ class ClideMarkdown extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w700), children: [ for (final c in el.children ?? const []) - if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap) + if (c is md.Text) ..._linkifyText(_unescapeHtml(c.text), tokens, onRecordTap) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap) ], ); case 'em': @@ -279,7 +289,7 @@ class ClideMarkdown extends StatelessWidget { style: const TextStyle(fontStyle: FontStyle.italic), children: [ for (final c in el.children ?? const []) - if (c is md.Text) TextSpan(text: _unescapeHtml(c.text)) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap) + if (c is md.Text) ..._linkifyText(_unescapeHtml(c.text), tokens, onRecordTap) else if (c is md.Element) _inlineElementSpan(c, tokens, onRecordTap) ], ); case 'code': @@ -290,24 +300,7 @@ class ClideMarkdown extends StatelessWidget { case 'a': final text = _unescapeHtml(el.textContent); if (onRecordTap != null && _recordPattern.hasMatch(text)) { - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: ClideTappable( - onTap: () => onRecordTap(text), - builder: (_, hovered, __) => Text( - text, - style: TextStyle( - color: tokens.globalFocus, - fontSize: _fontSize, - height: _lineHeight, - fontFamily: clideMonoFamily, - decoration: hovered ? TextDecoration.underline : null, - decorationColor: tokens.globalFocus, - ), - ), - ), - ); + return _recordLinkSpan(text, tokens, onRecordTap); } return TextSpan( text: text, @@ -322,4 +315,44 @@ class ClideMarkdown extends StatelessWidget { return TextSpan(text: _unescapeHtml(el.textContent)); } } + + /// Splits plain [text] into spans, turning bare governance/ticket refs + /// (T-281, D-77, Q-5, R-2) into clickable [_recordLinkSpan]s (T-279). With no + /// [onRecordTap] (or no match) the text passes through unchanged. + static List _linkifyText(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) { + if (onRecordTap == null || text.isEmpty) return [TextSpan(text: text)]; + final spans = []; + var last = 0; + for (final m in _bareRecordPattern.allMatches(text)) { + if (m.start > last) spans.add(TextSpan(text: text.substring(last, m.start))); + spans.add(_recordLinkSpan(m[0]!, tokens, onRecordTap)); + last = m.end; + } + if (last < text.length) spans.add(TextSpan(text: text.substring(last))); + return spans.isEmpty ? [TextSpan(text: text)] : spans; + } + + /// A clickable record-reference span: [id] rendered in the focus accent with + /// a hover underline, firing [onRecordTap] on tap (T-279). Shared by bare-text + /// refs and record-shaped markdown links so both look and behave alike. + static InlineSpan _recordLinkSpan(String id, SurfaceTokens tokens, RecordTapCallback onRecordTap) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: ClideTappable( + onTap: () => onRecordTap(id), + builder: (_, hovered, __) => Text( + id, + style: TextStyle( + color: tokens.globalFocus, + fontSize: _fontSize, + height: _lineHeight, + fontFamily: clideMonoFamily, + decoration: hovered ? TextDecoration.underline : null, + decorationColor: tokens.globalFocus, + ), + ), + ), + ); + } } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 0378d2d0..465e6655 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -172,6 +172,28 @@ void main() { expect(find.text('Waiting for Claude…'), findsOneWidget); }); + testWidgets('clicking a bare T-ref in a message opens the tickets reader (T-279)', (tester) async { + Message? opened; + final sub = f.services.messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen((m) => opened = m); + addTearDown(sub.cancel); + await pumpWith(tester, [_user('please look at T-281')]); + await tester.tap(find.text('T-281')); + await tester.pumpAndSettle(); + expect(opened, isNotNull); + expect(opened!.data['id'], 'T-281'); + }); + + testWidgets('clicking a bare D-ref opens the decisions reader (T-279)', (tester) async { + Message? opened; + final sub = f.services.messages.subscribe(publisher: 'builtin.decisions', channel: 'selection').listen((m) => opened = m); + addTearDown(sub.cancel); + await pumpWith(tester, [_asst('we resume cleanly per D-77 today')]); + await tester.tap(find.text('D-77')); + await tester.pumpAndSettle(); + expect(opened, isNotNull); + expect(opened!.data['id'], 'D-77'); + }); + testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async { await pumpWith( tester, diff --git a/test/widgets/more_widgets_test.dart b/test/widgets/more_widgets_test.dart index aaf15e5c..94ff06c6 100644 --- a/test/widgets/more_widgets_test.dart +++ b/test/widgets/more_widgets_test.dart @@ -98,6 +98,60 @@ After. expect(tapped, 'D-1'); }); + testWidgets('bare T-NNN ref in prose is tappable and fires onRecordTap (T-279)', (tester) async { + var tapped = ''; + const src = 'See T-281 for the follow-up.'; + await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id))); + await tester.pumpAndSettle(); + // The bare ref is pulled out into a standalone tappable Text span. + await tester.tap(find.text('T-281')); + await tester.pumpAndSettle(); + expect(tapped, 'T-281'); + }); + + testWidgets('bare D/Q/R refs all linkify (T-279)', (tester) async { + var tapped = ''; + // One pump (re-pumping harness() reuses the first Overlay entry), three refs. + await tester.pumpWidget(harness(f, ClideMarkdown('refs D-77 and Q-5 and R-2 here', onRecordTap: (x) => tapped = x))); + await tester.pumpAndSettle(); + for (final id in ['D-77', 'Q-5', 'R-2']) { + await tester.tap(find.text(id)); + await tester.pumpAndSettle(); + expect(tapped, id, reason: '$id should be a clickable ref'); + } + }); + + testWidgets('a bare word like T-shirt is not linkified (T-279)', (tester) async { + var tapped = ''; + const src = 'buy a T-shirt today'; + await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id))); + await tester.pumpAndSettle(); + // No digits after the dash → no match → no standalone link span; the + // phrase stays inside the paragraph RichText. + expect(find.text('T-shirt'), findsNothing); + expect(tapped, isEmpty); + }); + + testWidgets('a ref inside an inline code span stays plain (T-279)', (tester) async { + var tapped = ''; + const src = 'run `pql ticket show T-281` to see it'; + await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id))); + await tester.pumpAndSettle(); + // The code span renders verbatim within the paragraph, not as a + // standalone tappable link. + expect(find.text('T-281'), findsNothing); + expect(tapped, isEmpty); + }); + + testWidgets('without onRecordTap a bare ref renders as plain text (T-279)', (tester) async { + const src = 'See T-281 here'; + await tester.pumpWidget(harness(f, const ClideMarkdown(src))); + await tester.pumpAndSettle(); + // No callback → no link span carved out. + expect(find.text('T-281'), findsNothing); + expect(find.byType(ClideMarkdown), findsOneWidget); + }); + testWidgets('h3 / h4 / h5 / h6 headings render with the right padding tier', (tester) async { const src = '### h3\n\n#### h4\n\n##### h5\n\n###### h6\n'; await tester.pumpWidget(harness(f, const ClideMarkdown(src)));