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
@@ -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)));