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