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
+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,
),
),
),
);
}
}