clickable file references in the Claude conversation (T-300)

Workspace file paths mentioned by Claude now linkify and open in the
editor: bare (lib/app.dart), with a line (lib/app.dart:42), backticked,
or as markdown links. Only paths that exist in the repo linkify — the
resolver gates on existence so prose (version numbers, "e.g.") stays
literal. Clicking maps to the editor.open verb, jumping to the line when
a :line suffix is present (D-6 parity).

ClideMarkdown gains resolveFileRef + onOpenFile hooks; conversation_view
resolves against the open project root + existsSync and dispatches over
IPC. Detection covers running prose, whole-content code spans, and link
hrefs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:55:53 +02:00
co-authored by Claude Opus 4.8
parent 984bf0cfaf
commit 026e331146
7 changed files with 387 additions and 19 deletions
@@ -3182,3 +3182,7 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB37JZSFZKWPK9PDFYJY2YC0', 'status', 'ready', 'done', NULL, '2026-06-10 13:18:57', '2026-06-10 13:18:57', '2026-06-10 13:18:57', NULL, '02a8ea1b2623be255101f56c077f63ee', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:24:15', '2026-06-10 13:24:15', '2026-06-10 13:24:15', NULL, '85d0441183cc6f64fb1a956522a60f4a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'ready', 'in_progress', NULL, '2026-06-10 13:41:17', '2026-06-10 13:41:17', '2026-06-10 13:41:17', NULL, '14fa1745f31d356412fe910180183a53', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:53:28', '2026-06-10 13:53:28', '2026-06-10 13:53:28', NULL, 'addfc7d2a002212e979b3d7237cf37ce', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:53:55', '2026-06-10 13:53:55', '2026-06-10 13:53:55', NULL, '72773d8d91af176305bd6c1b7d5d65be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:54:02', '2026-06-10 13:54:02', '2026-06-10 13:54:02', NULL, '5cb488441033ac5a781d4b7d91dbda6f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'in_progress', 'done', NULL, '2026-06-10 13:55:30', '2026-06-10 13:55:30', '2026-06-10 13:55:30', NULL, 'f618ee8a3a33dabfa822bd0cebae4e03', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -164,3 +164,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DWCJSGZH9WYDNFWZBAYYR', 'T-337', '2026-06-10 13:27:36', '2026-06-10 13:27:36', NULL, '94b0ebd244a6794e0e4e5a868b38d67f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'T-338', '2026-06-10 13:47:04', '2026-06-10 13:47:04', NULL, 'be9b536d471a6008cc854092dfa6aaa4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'T-339', '2026-06-10 13:48:18', '2026-06-10 13:48:18', NULL, 'e76a2ce5d3149bb7ca0ec8a45f5c57a2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'T-340', '2026-06-10 13:53:23', '2026-06-10 13:53:23', NULL, 'bdd164a73576f6ca5151c8a62640526f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+67
View File
@@ -2515,3 +2515,70 @@ Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 13:48:18', NULL, '33c0a8b0a26f1012dffc3dfa1e742463', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied this action is too complex...'' block over a follow-up retry).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 13:53:23', NULL, 'b00b71e6716b515b5765abe808923132', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Agent prompt renders as a ''you'' card instead of folding into the Activity Agent card', 'When Claude spawns a sub-agent (Agent/Task tool, rendered as an ''Activity Agent'' card), the prompt handed to the sub-agent renders as a separate top-level UserMessage card labelled ''you'' (blue, globalFocus accent) above the card, instead of folding into the Activity Agent card as a muted ''agent prompt'' (the T-263 behaviour).
Repro: launch any sub-agent from the Claude pane; the task prompt appears as a ''you'' turn.
Root cause: the sidechain fold keys entirely off the transcript envelope. In transcript_reader.dart the prompt''s UserMessage carries isSidechain/parentUuid straight from envelope[''isSidechain''] and envelope[''parentUuid''] (transcript_reader.dart:558-560, parsed in _parseUserInto at :593-643). conversation_view.dart _sidechainFold() (:179-233) only claims an item when it.isSidechain is true AND resolveOwner() walks its parentUuid chain up to the Agent tool_use message (or the nearest preceding Agent as fallback). If the spawning prompt''s envelope arrives WITHOUT isSidechain==true (or without a parentUuid that roots at the Agent), the item is never added to ownedSidechainUuids, _visibleItems() (:116-160) never drops it, and _ConversationTurn.build() falls through past the ''when i.injected || i.isSidechain'' guard into the default UserMessage() case => label: ''you'' (the blue user card).
Need to confirm whether the prompt envelope is missing isSidechain (likely the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too.
Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 13:53:28', NULL, 'bd4b80fac2b7afc66056ca93d3281736', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
Implementation sketch:
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
- Avoid brittle string matching against _kDenySimplifyNote.
Acceptance:
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
- A real Bash error still renders expanded by default.
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied this action is too complex...'' block over a follow-up retry).', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 13:53:55', NULL, '11e5f8e73fda443dfd532cbb20219b52', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button.
Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state.
Implementation notes:
- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses).
- The ''id'' is already in the message payload currently only ''prompt'' is read; also read ''id''.
- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour.
- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`.
- No live session => no injection => no state change (preserve existing no-op semantics).
Acceptance:
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
- Picking up with no live session leaves the ticket untouched.
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 13:54:02', NULL, 'a8639457defad7f7e958c9fd0c567d97', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Clickable file references in Claude conversation open in editor', 'Make file-path references rendered in the Claude conversation pane clickable: clicking a path opens that file in the editor.
**Behavior**
- Detect references to files in the workspace as the conversation markdown is rendered bare paths (`lib/app.dart`), `file:line` forms (`lib/app.dart:42`, clickable per CLAUDE.md), and likely inline-code spans / markdown links pointing at repo paths.
- Resolve against the workspace root (CLIDE_WORKSPACE / git repo root). Only linkify paths that exist in the repo to avoid false positives on prose.
- On click, open in the editor same path as `clide editor open <path>` and jump to the line when a `:line` suffix is present.
**Notes / constraints**
- Markdown rendering is clide-owned custom CustomPaint/widgets (not a package), so detection + hit-testing lands in the conversation/markdown render path.
- Honors User/Claude parity (D-6): the click maps to the existing `clide editor open` verb.
- Open questions to settle during design: how aggressive path detection should be (existence check vs. heuristic), handling of non-existent / external paths, and visual affordance (underline/hover) for a linkified ref.', 'done', 'medium', NULL, NULL, NULL, '2026-06-09 20:02:41', '2026-06-10 13:55:30', NULL, 'd4022c088022495e2a0bda625a45c636', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Clickable file references in the Claude conversation.** Workspace file paths
mentioned by Claude — bare (`lib/app.dart`), with a line (`lib/app.dart:42`),
backticked, or as markdown links — are now clickable and open in the editor,
jumping to the line when present. Only paths that actually exist in the repo
linkify, so prose like version numbers stays literal. (T-300)
- **Hand a ticket to Claude from the sidebar.** Hovering a ticket card reveals a
run icon; clicking it hands the full ticket to the active Claude pane as a
"pick this up and start" prompt. Routed over the message bus, so the sidebar
+29 -1
View File
@@ -11,6 +11,7 @@ library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/conversation_card.dart';
@@ -365,6 +366,25 @@ void _openUrl(BuildContext context, String url) {
unawaited(ClideKernel.of(context).os.openURL(url));
}
/// Resolve a path-like token from the conversation to an absolute workspace
/// file, or null if it doesn't name a real repo file (T-300). The existence
/// check is what keeps prose ("e.g.", "2.2.0") from linkifying. Resolves
/// relative tokens against the open project root; absolute tokens must already
/// live inside it. `..` segments are rejected so a ref can't escape the repo.
String? _resolveRepoFile(BuildContext context, String raw) {
final root = ClideKernel.of(context).project.current?.path;
if (root == null || raw.isEmpty || raw.contains('..')) return null;
final abs = raw.startsWith('/') ? raw : '$root/$raw';
if (!abs.startsWith('$root/')) return null;
return File(abs).existsSync() ? abs : null;
}
/// Open a clicked workspace file reference in the editor, jumping to [line]
/// when present — the Dart-side twin of `clide editor open <path>` (T-300, D-6).
void _openFile(BuildContext context, String path, int? line) {
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, if (line != null) 'line': line}));
}
/// One conversation item, rendered by kind.
class _ConversationTurn extends StatelessWidget {
const _ConversationTurn({
@@ -443,6 +463,8 @@ class _ConversationTurn extends StatelessWidget {
onRecordTap: (id) => _openRecord(context, id),
onImageToken: (path) => ImageThumbnail(path: path, size: 48),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
@@ -453,7 +475,13 @@ class _ConversationTurn extends StatelessWidget {
label: i.isSidechain ? 'agent' : 'claude',
copyText: i.text,
margin: _childMargin,
body: ClideMarkdown(i.text, onRecordTap: (id) => _openRecord(context, id), onLinkTap: (url) => _openUrl(context, url)),
body: ClideMarkdown(
i.text,
onRecordTap: (id) => _openRecord(context, id),
onLinkTap: (url) => _openUrl(context, url),
resolveFileRef: (p) => _resolveRepoFile(context, p),
onOpenFile: (path, line) => _openFile(context, path, line),
),
),
AssistantThinkingMessage() => ConversationCard(
// Framed + muted like the context card (T-306).
+153 -18
View File
@@ -19,13 +19,23 @@ typedef ImageTokenBuilder = Widget Function(String path);
/// it (e.g. the OS URL handler).
typedef LinkTapCallback = void Function(String url);
/// Resolves a path-like token to an absolute path if it names a file that
/// exists in the workspace, else null (T-300). The caller owns workspace-root
/// resolution + the existence check; a null resolver — or a null return —
/// leaves the token literal, so prose like "e.g." or "2.2.0" never linkifies.
typedef FileRefResolver = String? Function(String path);
/// Open a resolved workspace file in the editor, jumping to [line] when a
/// `path:line` suffix was present (T-300).
typedef FileTapCallback = void Function(String path, int? line);
/// The interaction hooks a [ClideMarkdown] render may fire — bundled into one
/// value so the render tree threads a single object instead of a growing list
/// of optional callbacks. All optional; a null hook leaves that affordance
/// inert (the text renders, just not interactive).
@immutable
class ClideMarkdownHooks {
const ClideMarkdownHooks({this.onRecordTap, this.onImageToken, this.onLinkTap});
const ClideMarkdownHooks({this.onRecordTap, this.onImageToken, this.onLinkTap, this.resolveFileRef, this.onOpenFile});
/// Tap a governance/ticket ref (T-281, D-77, …) → open the record (T-279).
final RecordTapCallback? onRecordTap;
@@ -36,11 +46,18 @@ class ClideMarkdownHooks {
/// Open an activated http(s) link (T-253).
final LinkTapCallback? onLinkTap;
/// Confirm a path-like token names a real workspace file (T-300). Both this
/// and [onOpenFile] must be set for file refs to linkify.
final FileRefResolver? resolveFileRef;
/// Open a resolved workspace file in the editor (T-300).
final FileTapCallback? onOpenFile;
static const none = ClideMarkdownHooks();
}
class ClideMarkdown extends StatelessWidget {
const ClideMarkdown(this.source, {super.key, this.onRecordTap, this.onImageToken, this.onLinkTap});
const ClideMarkdown(this.source, {super.key, this.onRecordTap, this.onImageToken, this.onLinkTap, this.resolveFileRef, this.onOpenFile});
static const double _fontSize = 16;
static const double _lineHeight = clideLineHeight;
@@ -62,10 +79,21 @@ class ClideMarkdown extends StatelessWidget {
/// a preceding space qualifies, which is how the composer emits them.
static final _imageTokenPattern = RegExp(r'(?<![^\s])@(\S+\.(?:png|jpe?g|gif|webp|bmp))', caseSensitive: false);
/// A workspace file reference in running text (T-300): an optional `/`-rooted
/// path of slash-separated segments ending in a `name.ext`, plus an optional
/// `:line` (and ignored `:col`). The leading lookbehind keeps it from
/// starting mid-token. Group 1 is the path, group 2 the line. The existence
/// check (resolver) is the real gate — this only narrows the candidates, so
/// "version 2.2.0" (ext `0`, not a letter) and "e.g." (no such file) stay
/// literal even when they slip through.
static final _filePathPattern = RegExp(r'(?<![\w@./\-])(/?(?:[\w.\-]+/)*[\w\-][\w.\-]*\.[A-Za-z][\w]*)(?::(\d+))?(?::\d+)?');
final String source;
final RecordTapCallback? onRecordTap;
final ImageTokenBuilder? onImageToken;
final LinkTapCallback? onLinkTap;
final FileRefResolver? resolveFileRef;
final FileTapCallback? onOpenFile;
static String _unescapeHtml(String s) {
return s
@@ -82,7 +110,13 @@ class ClideMarkdown extends StatelessWidget {
final tokens = ClideTheme.of(context).surface;
final doc = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored);
final nodes = doc.parseLines(source.split('\n'));
final hooks = ClideMarkdownHooks(onRecordTap: onRecordTap, onImageToken: onImageToken, onLinkTap: onLinkTap);
final hooks = ClideMarkdownHooks(
onRecordTap: onRecordTap,
onImageToken: onImageToken,
onLinkTap: onLinkTap,
resolveFileRef: resolveFileRef,
onOpenFile: onOpenFile,
);
final widgets = _buildNodes(nodes, tokens, hooks);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -331,8 +365,15 @@ class ClideMarkdown extends StatelessWidget {
],
);
case 'code':
final raw = _unescapeHtml(el.textContent);
// A backticked path (`lib/app.dart:42`) → clickable, opening in the
// editor (T-300); other inline code renders verbatim.
if (hooks.resolveFileRef != null && hooks.onOpenFile != null) {
final ref = _codeFileRef(raw, hooks.resolveFileRef!);
if (ref != null) return _fileLinkSpan(raw, ref.$1, ref.$2, tokens, hooks.onOpenFile!, mono: true);
}
return TextSpan(
text: _unescapeHtml(el.textContent),
text: raw,
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.syntaxString, backgroundColor: tokens.panelBackground),
);
case 'a':
@@ -346,6 +387,13 @@ class ClideMarkdown extends StatelessWidget {
if (hooks.onLinkTap != null && href != null && _isHttpUrl(href)) {
return _urlLinkSpan(text, href, tokens, hooks.onLinkTap!);
}
// A link whose href points at an existing workspace file → open it in
// the editor (T-300).
if (hooks.resolveFileRef != null && hooks.onOpenFile != null && href != null) {
final (path, line) = _splitFileRef(href);
final abs = hooks.resolveFileRef!(path);
if (abs != null) return _fileLinkSpan(text, abs, line, tokens, hooks.onOpenFile!);
}
return TextSpan(
text: text,
style: TextStyle(color: tokens.globalFocus),
@@ -361,40 +409,89 @@ class ClideMarkdown extends StatelessWidget {
}
/// Splits plain [text] into spans: pasted-image `@<path>` tokens become inline
/// image widgets via [onImageToken] (T-236), and bare governance/ticket refs
/// (T-281, D-77, Q-5, R-2) become clickable [_recordLinkSpan]s (T-279). With
/// neither callback (or no match) the text passes through unchanged.
/// image widgets via [onImageToken] (T-236), with the prose between them run
/// through [_linkifyProse] (record + file refs). With no hooks (or no match)
/// the text passes through unchanged.
static List<InlineSpan> _linkifyText(String text, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
if (text.isEmpty) return [TextSpan(text: text)];
// Pass 1: pull out image tokens, record-linkifying the prose between them.
// Pass 1: pull out image tokens, linkifying the prose between them.
if (hooks.onImageToken != null) {
final spans = <InlineSpan>[];
var last = 0;
for (final m in _imageTokenPattern.allMatches(text)) {
if (m.start > last) spans.addAll(_linkifyRecords(text.substring(last, m.start), tokens, hooks.onRecordTap));
if (m.start > last) spans.addAll(_linkifyProse(text.substring(last, m.start), tokens, hooks));
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 2), child: hooks.onImageToken!(m.group(1)!)),
));
last = m.end;
}
if (last < text.length) spans.addAll(_linkifyRecords(text.substring(last), tokens, hooks.onRecordTap));
if (last < text.length) spans.addAll(_linkifyProse(text.substring(last), tokens, hooks));
return spans.isEmpty ? [TextSpan(text: text)] : spans;
}
return _linkifyRecords(text, tokens, hooks.onRecordTap);
return _linkifyProse(text, tokens, hooks);
}
static List<InlineSpan> _linkifyRecords(String text, SurfaceTokens tokens, RecordTapCallback? onRecordTap) {
if (onRecordTap == null || text.isEmpty) return [TextSpan(text: text)];
/// Linkifies running prose: governance/ticket refs (T-279) and workspace file
/// references (T-300) both become clickable spans, plain text between passing
/// through. File refs only linkify when [ClideMarkdownHooks.resolveFileRef]
/// confirms the path exists, so non-file dotted prose stays literal. On
/// overlap the earlier match wins (records and file paths don't collide in
/// practice — one needs a `.ext`, the other forbids one).
static List<InlineSpan> _linkifyProse(String text, SurfaceTokens tokens, ClideMarkdownHooks hooks) {
if (text.isEmpty) return [TextSpan(text: text)];
final wantRecords = hooks.onRecordTap != null;
final wantFiles = hooks.resolveFileRef != null && hooks.onOpenFile != null;
if (!wantRecords && !wantFiles) return [TextSpan(text: text)];
final hits = <_LinkHit>[];
if (wantRecords) {
for (final m in _bareRecordPattern.allMatches(text)) {
hits.add(_LinkHit(m.start, m.end, _recordLinkSpan(m[0]!, tokens, hooks.onRecordTap!)));
}
}
if (wantFiles) {
for (final m in _filePathPattern.allMatches(text)) {
final abs = hooks.resolveFileRef!(m.group(1)!);
if (abs == null) continue;
final line = m.group(2) == null ? null : int.tryParse(m.group(2)!);
hits.add(_LinkHit(m.start, m.end, _fileLinkSpan(text.substring(m.start, m.end), abs, line, tokens, hooks.onOpenFile!)));
}
}
if (hits.isEmpty) return [TextSpan(text: text)];
hits.sort((a, b) => a.start.compareTo(b.start));
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;
for (final h in hits) {
if (h.start < last) continue; // overlapped by an earlier match
if (h.start > last) spans.add(TextSpan(text: text.substring(last, h.start)));
spans.add(h.span);
last = h.end;
}
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
return spans.isEmpty ? [TextSpan(text: text)] : spans;
return spans;
}
/// If a code span's whole content is a single workspace file ref (`path` or
/// `path:line`), returns (absPath, line); else null (T-300). Requires a
/// full-content single-token match so multi-word inline code stays verbatim.
static (String, int?)? _codeFileRef(String content, FileRefResolver resolve) {
final s = content.trim();
if (s.isEmpty || s.contains(RegExp(r'\s'))) return null;
final m = _filePathPattern.firstMatch(s);
if (m == null || m.start != 0 || m.end != s.length) return null;
final abs = resolve(m.group(1)!);
if (abs == null) return null;
return (abs, m.group(2) == null ? null : int.tryParse(m.group(2)!));
}
/// Splits a `path` or `path:line` (also `path:line:col`) href into its path
/// and optional 1-based line (T-300).
static (String, int?) _splitFileRef(String raw) {
final m = RegExp(r'^(.*?):(\d+)(?::\d+)?$').firstMatch(raw);
if (m != null) return (m.group(1)!, int.tryParse(m.group(2)!));
return (raw, null);
}
/// A clickable record-reference span: [id] rendered in the focus accent with
@@ -426,6 +523,33 @@ class ClideMarkdown extends StatelessWidget {
return u != null && (u.scheme == 'http' || u.scheme == 'https') && u.host.isNotEmpty;
}
/// A clickable workspace file-reference span (T-300): the path [display] in
/// the focus accent, underlined on hover, opening the resolved [absPath] at
/// [line] (when present) in the editor via [onOpenFile]. The [mono] flag keeps
/// backticked refs in the monospace face; prose refs use the UI face.
static InlineSpan _fileLinkSpan(String display, String absPath, int? line, SurfaceTokens tokens, FileTapCallback onOpenFile, {bool mono = false}) {
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: ClideTappable(
onTap: () => onOpenFile(absPath, line),
tooltip: 'Open in editor',
builder: (_, hovered, __) => Text(
display,
style: TextStyle(
color: tokens.globalFocus,
fontSize: mono ? clideFontMono : _fontSize,
height: _lineHeight,
fontFamily: mono ? clideMonoFamily : clideUiFamily,
fontFamilyFallback: mono ? null : clideUiFamilyFallback,
decoration: hovered ? TextDecoration.underline : null,
decorationColor: tokens.globalFocus,
),
),
),
);
}
/// A clickable http(s) link span (T-253): the link [text] in the focus accent,
/// underlined on hover, opening [href] via [onLinkTap]. Keyboard-activatable
/// (ClideTappable) and tooltipped with the destination.
@@ -452,3 +576,14 @@ class ClideMarkdown extends StatelessWidget {
);
}
}
/// One linkified span and the `[start, end)` slice of the prose it covers, so
/// [ClideMarkdown._linkifyProse] can merge record + file matches in order and
/// drop overlaps.
@immutable
class _LinkHit {
const _LinkHit(this.start, this.end, this.span);
final int start;
final int end;
final InlineSpan span;
}
@@ -0,0 +1,128 @@
/// Tests for ClideMarkdown workspace file references (T-300): paths that the
/// resolver confirms exist become clickable and open in the editor; everything
/// else stays literal. The resolver is stubbed so no real filesystem is touched.
library;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
// Resolves only the one known repo path; everything else is "not a file".
String? resolve(String p) => p == 'lib/app.dart' ? '/repo/lib/app.dart' : null;
ClideMarkdown md(String src, {void Function(String, int?)? onOpen}) => ClideMarkdown(
src,
resolveFileRef: resolve,
onOpenFile: onOpen ?? (_, __) {},
);
testWidgets('a bare repo path in prose is tappable and opens with no line', (tester) async {
String? path;
int? line = -1;
await tester.pumpWidget(harness(f, md('see lib/app.dart for the entry point', onOpen: (p, l) {
path = p;
line = l;
})));
await tester.pump();
await tester.tap(find.text('lib/app.dart'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
expect(line, isNull);
});
testWidgets('a path:line ref opens at the line', (tester) async {
String? path;
int? line;
await tester.pumpWidget(harness(f, md('crash at lib/app.dart:42 today', onOpen: (p, l) {
path = p;
line = l;
})));
await tester.pump();
await tester.tap(find.text('lib/app.dart:42'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
expect(line, 42);
});
testWidgets('a path:line:col ref opens at the line, ignoring the column', (tester) async {
int? line;
await tester.pumpWidget(harness(f, md('lib/app.dart:42:8', onOpen: (_, l) => line = l)));
await tester.pump();
await tester.tap(find.text('lib/app.dart:42:8'));
await tester.pump();
expect(line, 42);
});
testWidgets('a backticked path is clickable', (tester) async {
String? path;
int? line;
await tester.pumpWidget(harness(f, md('open `lib/app.dart:7`', onOpen: (p, l) {
path = p;
line = l;
})));
await tester.pump();
await tester.tap(find.text('lib/app.dart:7'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
expect(line, 7);
});
testWidgets('a markdown [text](path) link opens the resolved href file', (tester) async {
String? path;
await tester.pumpWidget(harness(f, md('[the app](lib/app.dart)', onOpen: (p, _) => path = p)));
await tester.pump();
await tester.tap(find.text('the app'));
await tester.pump();
expect(path, '/repo/lib/app.dart');
});
testWidgets('a path that does not resolve stays literal (no link, no fire)', (tester) async {
var calls = 0;
await tester.pumpWidget(harness(f, md('nothing at lib/ghost.dart here', onOpen: (_, __) => calls++)));
await tester.pumpAndSettle();
// Rendered as plain text — tapping it does nothing.
await tester.tap(find.textContaining('lib/ghost.dart'), warnIfMissed: false);
await tester.pump();
expect(calls, 0);
});
testWidgets('dotted prose (a version number) does not linkify', (tester) async {
var calls = 0;
await tester.pumpWidget(harness(f, md('shipped version 2.2.0 today', onOpen: (_, __) => calls++)));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('2.2.0'), warnIfMissed: false);
await tester.pump();
expect(calls, 0);
});
testWidgets('with no file hooks, a path renders inert (no crash)', (tester) async {
await tester.pumpWidget(harness(f, const ClideMarkdown('see lib/app.dart here')));
await tester.pumpAndSettle();
expect(find.textContaining('lib/app.dart'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a backticked non-path code span stays verbatim (not linkified)', (tester) async {
await tester.pumpWidget(harness(f, md('run `flutter test` now')));
await tester.pumpAndSettle();
// A linkified ref renders as its own tappable Text; an inert code span is a
// styled run inside the paragraph, so it is not a standalone Text widget.
expect(find.text('flutter test'), findsNothing);
expect(tester.takeException(), isNull);
});
}