diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..8c42278a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -386,7 +386,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`. -**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. `test/response-viewer-file-links.test.ts` pins both properties. +**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite. **Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term. diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 820710ad..b21b896e 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -131,7 +131,7 @@ already writing in the editor. ## Tapping, links and copying -- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's +- **Tap a link** in terminal output and it opens in a new tab. Same for a web link in an agent's answer in the response viewer — it opens a tab rather than navigating the dashboard away, which on a phone would unload the whole session view. - **Tap a file path** an agent printed, or a link to one in its answer (Codex writes diff --git a/src/web/public/app.js b/src/web/public/app.js index 6d53cb4d..5ac677de 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2959,6 +2959,12 @@ class CodemanApp { * output. Subtrees already inside an `` are skipped so an autolinked URL * is never re-cut, and the anchor's textContent is the path verbatim, so * "copy code" still yields exactly what the agent printed. + * + * Before that walk it also rewrites an existing anchor whose href is a path + * (no scheme, not `//host`, not a bare `#fragment`) into the same `a.rv-path` + * shape, on purpose WITHOUT `FILE_PATH_LINK_PATTERN`'s root and extension + * filter: the agent chose that href as a link target, and left alone it opens + * as a same-origin URL (a 404 page in a new tab). */ _linkifyFilePaths(root) { if (!root || typeof document === 'undefined') return; @@ -2984,7 +2990,9 @@ class CodemanApp { } // A bare `#fragment` (and every `.rv-path`, whose href is `#`) ends up empty. if (!path) continue; - a.className = 'rv-path'; + // rv-path-link keeps the label in the prose font (styles.css): it is a label, + // not a path, and break-all would split it mid-word on a phone. + a.className = 'rv-path rv-path-link'; a.dataset.path = path; a.dataset.linkified = '1'; if (path.endsWith('/') || findFilePathLinks(path).some((f) => f.folder && f.path === path)) a.dataset.folder = '1'; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 624364d9..8fcc9cb5 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -14926,6 +14926,14 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat cursor: pointer; } +/* A markdown link whose target is a path (`[Our incident record](/tmp/x.json)`) + takes the rv-path shape for the click delegate, but its text is a label, not a + path: keep it in the prose font and never break it mid-word. */ +.rv-text a.rv-path.rv-path-link { + font: inherit; + word-break: normal; +} + /* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */ .rv-table-wrap { margin: 1em 0; diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index c30d2fd3..85947186 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -117,7 +117,7 @@ describe('response viewer file-path linkifier', () => { const root = linkify( `

Our incident record and ` + `a b, docs, renders, ` + - `release

` + `release, raw, tmp

` ); const links = paths(root); @@ -128,6 +128,9 @@ describe('response viewer file-path linkifier', () => { '/tmp/renders', // An encoded colon is part of the name, never a line suffix. '/tmp/release:12', + // A query names a view of the file, not the file. + '/tmp/a.json', + '/tmp/', ]); for (const a of links) { expect(a.getAttribute('href')).toBe('#'); @@ -136,9 +139,13 @@ describe('response viewer file-path linkifier', () => { expect(a.getAttribute('data-linkified')).toBe('1'); } expect(links[0].textContent).toBe('Our incident record'); + // A label, not a path: the second class keeps it in the prose font (styles.css). + expect(links.every((a) => a.classList.contains('rv-path-link'))).toBe(true); // A trailing slash and a rooted folder path without one both open the folder grid // (`:` ends a folder match in findFilePathLinks, so `release:12` previews instead). - expect(links.map((a) => a.hasAttribute('data-folder'))).toEqual([false, false, true, true, false]); + // findFilePathLinks finds no folder in a bare `/tmp/`, so only the trailing-slash + // check marks that one. + expect(links.map((a) => a.hasAttribute('data-folder'))).toEqual([false, false, true, true, false, false, true]); }); it('opens a clicked markdown path link in the file preview through the click delegate', () => {