From 026e33114647584305fac56e335947617c32c000 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 10 Jun 2026 15:55:53 +0200 Subject: [PATCH] clickable file references in the Claude conversation (T-300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 4 + .pql/changelog/ticket_idmap/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 67 +++++++ CHANGELOG.md | 5 + lib/builtin/claude/src/conversation_view.dart | 30 ++- lib/widgets/src/clide_markdown.dart | 171 ++++++++++++++++-- .../src/clide_markdown_file_ref_test.dart | 128 +++++++++++++ 7 files changed, 387 insertions(+), 19 deletions(-) create mode 100644 test/widgets/src/clide_markdown_file_ref_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 99b25db9..54aa81a9 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/ticket_idmap/2026-06.sql b/.pql/changelog/ticket_idmap/2026-06.sql index a1592409..4e22533a 100644 --- a/.pql/changelog/ticket_idmap/2026-06.sql +++ b/.pql/changelog/ticket_idmap/2026-06.sql @@ -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); diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 35e1f71e..70e207b1 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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 ` — 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 9263bd79..36debd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 9dd4dad8..a95161ca 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -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 ` (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). diff --git a/lib/widgets/src/clide_markdown.dart b/lib/widgets/src/clide_markdown.dart index 904304a4..5155a747 100644 --- a/lib/widgets/src/clide_markdown.dart +++ b/lib/widgets/src/clide_markdown.dart @@ -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'(?` 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 _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 = []; 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 _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 _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 = []; 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; +} diff --git a/test/widgets/src/clide_markdown_file_ref_test.dart b/test/widgets/src/clide_markdown_file_ref_test.dart new file mode 100644 index 00000000..24a13cdc --- /dev/null +++ b/test/widgets/src/clide_markdown_file_ref_test.dart @@ -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); + }); +}