clickable T/D/Q/R cross-refs in the Claude conversation (T-279)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:13:01 +02:00
co-authored by Claude Opus 4.8
parent 12032e8c2c
commit 5241876871
7 changed files with 225 additions and 24 deletions
@@ -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;
+71
View File
@@ -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);
+6
View File
@@ -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,
+11 -2
View File
@@ -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,
+55 -22
View File
@@ -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 = <InlineSpan>[];
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 = <InlineSpan>[];
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<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
if (onRecordTap == null || text.isEmpty) return [TextSpan(text: text)];
final spans = <InlineSpan>[];
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,
),
),
),
);
}
}
@@ -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,
+54
View File
@@ -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)));