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