.
const copyBtn = ev.target.closest('.rv-copy-btn');
if (copyBtn) {
@@ -2074,10 +2085,66 @@ class CodemanApp {
const renderedText = document.createElement('div');
renderedText.className = 'rv-text';
renderedText.innerHTML = this._renderMarkdown(text);
+ this._linkifyFilePaths(renderedText);
div.appendChild(renderedText);
return div;
}
+ /**
+ * Make absolute file paths in a rendered message clickable.
+ *
+ * The terminal's link provider never sees these: the response viewer is
+ * markdown, and a path the agent wrote as prose or inline code renders as
+ * inert text — so the file it just produced (a screenshot, a report) was one
+ * copy-paste away from being viewable instead of one click. Same pattern the
+ * terminal uses (constants.js), same destination (the file-preview overlay).
+ *
+ * Walks TEXT NODES and builds anchors with DOM APIs — never innerHTML, and
+ * never a string rebuild of already-sanitized markup: the source is model
+ * 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.
+ */
+ _linkifyFilePaths(root) {
+ if (!root || typeof document === 'undefined') return;
+ // Guarded: a stale cached constants.js must degrade to plain text, not throw
+ // out of the middle of rendering a message.
+ if (typeof absoluteFilePathPattern !== 'function') return;
+ const pattern = absoluteFilePathPattern();
+
+ // Collect first: replacing a node while the walker is positioned on it
+ // invalidates the traversal.
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const targets = [];
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ if (node.parentElement?.closest('a')) continue;
+ pattern.lastIndex = 0;
+ if (pattern.test(node.nodeValue || '')) targets.push(node);
+ }
+
+ for (const node of targets) {
+ const value = node.nodeValue;
+ const frag = document.createDocumentFragment();
+ let cursor = 0;
+ let match;
+ pattern.lastIndex = 0;
+ while ((match = pattern.exec(value)) !== null) {
+ const path = match[1];
+ if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
+ const link = document.createElement('a');
+ link.className = 'rv-path';
+ link.href = '#';
+ link.dataset.path = path;
+ link.title = path;
+ link.textContent = path;
+ frag.appendChild(link);
+ cursor = match.index + path.length;
+ }
+ if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
+ node.parentNode?.replaceChild(frag, node);
+ }
+ }
+
_getResponseViewerAgentLabel() {
const mode = this.sessions.get(this.activeSessionId)?.mode;
return mode === 'codex'
diff --git a/src/web/public/constants.js b/src/web/public/constants.js
index 56f3852a..0e5e0b6e 100644
--- a/src/web/public/constants.js
+++ b/src/web/public/constants.js
@@ -893,6 +893,45 @@ function computeRewriteScrollLine(input) {
return Math.max(0, (input?.baseY || 0) - linesFromBottom);
}
+/**
+ * Absolute file paths in agent output, as ONE pattern with two consumers: the
+ * xterm link provider (terminal-ui.js) and the response viewer's markdown
+ * linkifier (app.js). They used to be able to drift, and a path that is
+ * clickable in the terminal but inert in the chat reads as a bug, not a policy.
+ *
+ * Anchored on a known absolute root (so an ordinary fraction or a date can
+ * never match) and terminated by a known extension (so the end of the path is
+ * unambiguous — a trailing `)` or `.` after the extension stays out). Longer
+ * extensions come first in each family (`tsx|ts`), so the trailing `\b` cannot
+ * be satisfied by the shorter branch mid-word.
+ *
+ * ⚠ Consumers must never share one instance: `lastIndex` is per-object state on
+ * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
+ */
+const FILE_PATH_LINK_PATTERN =
+ /(\/(?:home|Users|tmp|var|private|etc|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
+
+/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
+function absoluteFilePathPattern() {
+ return new RegExp(FILE_PATH_LINK_PATTERN.source, 'g');
+}
+
+/**
+ * Extensions the file-preview overlay renders itself. Everything else a link
+ * points at goes to the tail/log viewer, which is the right home for a growing
+ * text file and the wrong one for bytes (tailing a PNG shows binary noise).
+ */
+const FILE_PREVIEW_EXTENSIONS = new Set(
+ ('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov mp3 wav').split(' ')
+);
+
+/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
+function previewsInFileViewer(filePath) {
+ const ext = String(filePath || '').split('.').pop().toLowerCase();
+ return FILE_PREVIEW_EXTENSIONS.has(ext);
+}
+
if (typeof window !== 'undefined') {
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
+ window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
}
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index fbec7464..7787f0af 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -3234,6 +3234,65 @@ Object.assign(CodemanApp.prototype, {
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
},
+ /**
+ * Whether a path is absolute and provably OUTSIDE this session's workspace.
+ *
+ * `file-content` / `file-raw` resolve every path against `workingDir` and
+ * refuse anything that escapes it, so an absolute path elsewhere on the host
+ * (an agent's `/tmp` scratchpad capture, a screenshot, another checkout) can
+ * only ever 404 there — it has to go through the attachment routes instead.
+ *
+ * A string compare is enough for ROUTING; the real containment decision stays
+ * server-side (realpath + guard) on whichever route the request lands on. An
+ * unknown workingDir answers false, leaving the historical path untouched.
+ */
+ _isExternalPreviewPath(filePath, sessionId) {
+ if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
+ const workingDir = this.sessions.get(sessionId)?.workingDir;
+ if (!workingDir) return false;
+ const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`;
+ return filePath !== workingDir && !filePath.startsWith(root);
+ },
+
+ /**
+ * Register an out-of-workspace path as a live external attachment and return
+ * its id, so the preview can render it through the by-id attachment routes.
+ *
+ * `notify: false` keeps this quiet: the caller is already opening the file in
+ * the overlay, so the usual attachment card + unread badge would be noise on
+ * top of the thing the user just asked to see. The server still enforces the
+ * full attachment guard (blocked secret trees, extension allowlist, symlinks
+ * resolved), so a refusal here is a policy answer worth showing verbatim.
+ *
+ * @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
+ */
+ async _registerExternalPreview(filePath, sessionId) {
+ try {
+ const res = await fetch(`/api/sessions/${sessionId}/attachments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: filePath, notify: false }),
+ });
+ const result = await res.json().catch(() => null);
+ if (res.ok && result?.success && result.data?.attachmentId) {
+ return { attachmentId: result.data.attachmentId, size: result.data.size || 0 };
+ }
+ const reason = result?.error || `Cannot open this file (HTTP ${res.status})`;
+ // The registry's type answer is a policy term, not an explanation, and the
+ // user just clicked a file they can see on disk. Say what IS previewable
+ // from outside the workspace instead.
+ if (/unsupported/i.test(reason)) {
+ const ext = (filePath.split('.').pop() || '').toLowerCase();
+ return {
+ error: `Cannot preview .${ext} from outside the session workspace (images, PDF, Office documents, Markdown and text only).`,
+ };
+ }
+ return { error: reason };
+ } catch (err) {
+ return { error: err.message || 'Cannot open this file' };
+ }
+ },
+
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
if (!sessionId || !filePath) return;
@@ -3258,13 +3317,34 @@ Object.assign(CodemanApp.prototype, {
const ext = (filePath.split('.').pop() || '').toLowerCase();
+ // Out-of-workspace path: mint an attachment id up front. Every branch below
+ // talks to a workspace-confined route, so without this the image/PDF ones
+ // render a broken frame and the text one reports a bare "File not found"
+ // for a file that is sitting right there on disk.
+ let externalError = '';
+ let externalSize = 0;
+ if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
+ const external = await this._registerExternalPreview(filePath, sessionId);
+ attachmentId = external.attachmentId || null;
+ externalError = external.error || '';
+ externalSize = external.size || 0;
+ }
+ if (!attachmentId && externalError) {
+ footerEl.textContent = '';
+ bodyEl.innerHTML = ``;
+ return;
+ }
+
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) {
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
- footerEl.textContent = ext.toUpperCase();
+ // Size when we just registered the file ourselves, so a path opened from a
+ // link reads like a workspace preview instead of a bare "PNG". History
+ // cards arrive with an id and no size and keep the short form.
+ footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `
`;
} else if (ext === 'pdf') {
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 5df82884..0b9b8395 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -9855,13 +9855,18 @@ kbd {
/* ========== File Preview Overlay ========== */
+/* Above the response viewer (5000) and its backdrop (4999): a file path in the
+ chat opens this overlay, and at the old 2000 it rendered BEHIND the panel it
+ was launched from — the click looked dead. Same relationship the path picker
+ and its preview already have (10020 / 10030). Still below the toast and
+ picker band (10000+), so a "Saved" toast keeps landing on top. */
.file-preview-overlay {
position: fixed;
inset: 0;
background: var(--modal-backdrop);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
- z-index: 2000;
+ z-index: 5100;
display: none;
align-items: center;
justify-content: center;
@@ -12413,6 +12418,16 @@ kbd {
border-bottom-color: var(--accent);
}
+/* File paths linkified out of the message text. Monospace so a path still reads
+ as a path in prose, and break-all because these are long and the viewer is
+ narrow on a phone. Colour/underline come from the .rv-text a rule above. */
+.rv-text a.rv-path {
+ font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
+ font-size: 0.92em;
+ word-break: break-all;
+ cursor: pointer;
+}
+
/* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */
.rv-table-wrap {
margin: 1em 0;
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 2c36011a..a212aa4d 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -1423,19 +1423,19 @@ Object.assign(CodemanApp.prototype, {
// the whole tab on hover. Non-empty token + bounded reps is O(n).
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
- // Pattern 2: Paths with common extensions.
- // Image/PDF extensions are included so pasted-attachment paths
- // (`.claude-images/paste-*.png`) are clickable; they open the file preview
- // rather than the log viewer (see addLink).
- const extPattern =
- /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
+ // Pattern 2: Paths with common extensions. Image/PDF/media extensions are
+ // included so pasted-attachment paths (`.claude-images/paste-*.png`) and
+ // screenshots an agent just wrote are clickable; those open the file
+ // preview rather than the log viewer (see addLink).
+ //
+ // The literal lives in constants.js because the response viewer linkifies
+ // the SAME paths out of markdown — one definition, two consumers. A fresh
+ // instance per call: `lastIndex` is per-object state.
+ const extPattern = absoluteFilePathPattern();
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
- /** Extensions that should open the image/document preview, not the log viewer. */
- const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
-
const addLink = (filePath, matchIndex) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
@@ -1454,9 +1454,10 @@ Object.assign(CodemanApp.prototype, {
},
activate(event, text) {
// Tailing a PNG in the log viewer shows binary noise; the file preview
- // already renders images and PDFs inline.
- const ext = (text.split('.').pop() || '').toLowerCase();
- if (PREVIEW_EXTS.has(ext)) {
+ // already renders images, PDFs, documents and media inline — and it
+ // now reaches files outside the workspace too, which is where an
+ // agent's screenshots and scratchpad captures actually land.
+ if (previewsInFileViewer(text)) {
self.openFilePreview(text, self.activeSessionId);
return;
}
diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts
index ede34a58..aa20b490 100644
--- a/src/web/routes/file-routes.ts
+++ b/src/web/routes/file-routes.ts
@@ -1449,7 +1449,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
app.post('/api/sessions/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
- const body = (req.body || {}) as { path?: string };
+ const body = (req.body || {}) as { path?: string; notify?: boolean };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
@@ -1458,7 +1458,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
try {
const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
- ctx.broadcast(SseEvent.AttachmentDetected, event);
+ // `notify: false` registers QUIETLY. The file-preview overlay uses it to
+ // mint an id for a path the user just clicked (a terminal or response-viewer
+ // link pointing outside the workspace): it is already opening the file, so
+ // the attachment card + unread badge would be noise announcing what is
+ // filling the screen. Default stays true — every other caller (the
+ // `codeman attach` CLI, codeman-publish) wants the card.
+ if (body.notify !== false) {
+ ctx.broadcast(SseEvent.AttachmentDetected, event);
+ }
return { success: true, data: event };
} catch (err) {
if (err instanceof AttachmentRegistrationError) {
diff --git a/test/link-provider-regex.test.ts b/test/link-provider-regex.test.ts
index e51f9f58..ed556337 100644
--- a/test/link-provider-regex.test.ts
+++ b/test/link-provider-regex.test.ts
@@ -18,18 +18,25 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
-const SOURCE = readFileSync(join(__dirname, '..', 'src', 'web', 'public', 'terminal-ui.js'), 'utf-8');
+const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8');
-/** Extract `const = /.../g;` from the shipped source and build the RegExp. */
+const SOURCE = publicFile('terminal-ui.js');
+// The file-path pattern lives in constants.js: the response viewer linkifies the
+// same paths out of markdown, and one definition is what keeps a path that is
+// clickable in the terminal from being inert in the chat.
+const CONSTANTS_SOURCE = publicFile('constants.js');
+
+/** Extract `const = /.../g;` from the shipped sources and build the RegExp. */
function shippedPattern(name: string): RegExp {
- const m = SOURCE.match(new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`));
- if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js`);
+ const literal = new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`);
+ const m = SOURCE.match(literal) ?? CONSTANTS_SOURCE.match(literal);
+ if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js or constants.js`);
const lit = m[1];
const lastSlash = lit.lastIndexOf('/');
return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
}
-const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'extPattern', 'bashPattern'];
+const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'FILE_PATH_LINK_PATTERN', 'bashPattern'];
/** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */
const KILLER_LINES = [
@@ -116,15 +123,24 @@ describe('terminal link-provider regexes (shipped source)', () => {
}
});
- it('extPattern links pasted image/PDF attachment paths', () => {
+ it('the file-path pattern links pasted image/PDF/media attachment paths', () => {
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
// without image extensions the path rendered as plain, unclickable text.
- const ext = shippedPattern('extPattern');
+ const ext = shippedPattern('FILE_PATH_LINK_PATTERN');
const cases = [
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
'/tmp/shot.jpeg',
'/opt/app/report.pdf',
'/home/a/diagram.svg',
+ // An agent's own scratchpad capture — the path shape this whole feature
+ // exists for, and the one that used to open a "File not found" preview.
+ '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png',
+ // macOS and WSL roots: unmatched before, so Mac users had no clickable
+ // paths at all outside /var and /tmp.
+ '/Users/arbbot/codeman-cases/report.docx',
+ '/mnt/d/captures/demo.mp4',
+ // Longer extension of a family must win over its prefix (tsx over ts).
+ '/home/a/src/App.tsx',
];
for (const path of cases) {
ext.lastIndex = 0;
@@ -134,6 +150,13 @@ describe('terminal link-provider regexes (shipped source)', () => {
}
});
+ it('terminal-ui builds its path pattern from the shared factory', () => {
+ // Structural guard: a local literal here would drift from the response
+ // viewer's linkifier, which is the divergence the move exists to prevent.
+ expect(SOURCE).toContain('absoluteFilePathPattern()');
+ expect(SOURCE).not.toMatch(/const extPattern =\s*\n?\s*\//);
+ });
+
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
// structural guard: the dangerous construct is an empty-matchable token
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts
new file mode 100644
index 00000000..07835e45
--- /dev/null
+++ b/test/response-viewer-file-links.test.ts
@@ -0,0 +1,137 @@
+/**
+ * @fileoverview Response-viewer file-path linkifier (`CodemanApp._linkifyFilePaths`).
+ *
+ * The viewer renders markdown, so a path an agent wrote — "wrote the chart to
+ * /tmp/.../chart.png" — arrived as inert text: the terminal's link provider
+ * never sees the chat, and the file it just produced was a copy-paste away
+ * instead of a click. The linkifier wraps those paths in an anchor the click
+ * delegate hands to the file-preview overlay.
+ *
+ * Two properties matter more than the linking itself and are pinned here:
+ *
+ * 1. **The text is untouched.** Anchors are built from TEXT NODES with DOM
+ * APIs, never by rebuilding already-sanitized markup as a string, so the
+ * message reads identically and "copy code" still yields exactly what the
+ * agent printed.
+ * 2. **Model output cannot become markup.** The source is model text; a
+ * path-shaped string carrying HTML must stay text.
+ *
+ * Loaded via `vm` with a jsdom document injected (same technique as
+ * connection-indicator.test.ts — no per-file jsdom environment, which would
+ * externalize node:fs under vite).
+ */
+import { readFileSync } from 'node:fs';
+import { performance } from 'node:perf_hooks';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+import { describe, expect, it, vi } from 'vitest';
+
+const dom = new JSDOM('');
+const { document, NodeFilter } = dom.window;
+
+function loadCodemanAppClass() {
+ const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
+ const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
+ const context = vm.createContext({
+ console,
+ performance,
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ setTimeout,
+ clearTimeout,
+ requestAnimationFrame: vi.fn(),
+ HTMLCanvasElement: class HTMLCanvasElement {},
+ fetch: vi.fn(),
+ document,
+ NodeFilter,
+ localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
+ window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
+ MobileDetection: {},
+ });
+ vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
+ return (context as { __CodemanApp: { prototype: { _linkifyFilePaths(root: unknown): void } } }).__CodemanApp;
+}
+
+const CodemanApp = loadCodemanAppClass();
+const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
+
+/** Render `html` into a detached .rv-text div and run the linkifier over it. */
+function linkify(html: string): HTMLElement {
+ const app = Object.create(CodemanApp.prototype) as { _linkifyFilePaths(root: unknown): void };
+ const root = document.createElement('div');
+ root.className = 'rv-text';
+ root.innerHTML = html;
+ app._linkifyFilePaths(root);
+ return root as unknown as HTMLElement;
+}
+
+const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path'));
+
+describe('response viewer file-path linkifier', () => {
+ it('links an absolute path written as prose', () => {
+ const path = '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png';
+ const root = linkify(`Saved the capture to ${path} — have a look.
`);
+
+ const links = paths(root);
+ expect(links).toHaveLength(1);
+ expect(links[0].getAttribute('data-path')).toBe(path);
+ expect(links[0].textContent).toBe(path);
+ expect(root.textContent).toBe(`Saved the capture to ${path} — have a look.`);
+ });
+
+ it('links a path inside inline code, which is how agents usually write one', () => {
+ const root = linkify('See /home/a/out/report.pdf for the numbers.
');
+
+ const links = paths(root);
+ expect(links).toHaveLength(1);
+ expect(links[0].getAttribute('data-path')).toBe('/home/a/out/report.pdf');
+ // Still inside the span — the code styling is not lost.
+ expect(links[0].closest('code')).not.toBeNull();
+ });
+
+ it('links every path in one text node and preserves the text between them', () => {
+ const root = linkify('Compare /tmp/before.png with /tmp/after.png please
');
+
+ expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(['/tmp/before.png', '/tmp/after.png']);
+ expect(root.textContent).toBe('Compare /tmp/before.png with /tmp/after.png please');
+ });
+
+ it('never re-cuts text already inside an anchor', () => {
+ // marked autolinks URLs; a path-looking tail inside one must stay whole, and
+ // a nested is invalid markup that would swallow the outer link's click.
+ const root = linkify('');
+
+ expect(paths(root)).toHaveLength(0);
+ expect(root.querySelectorAll('a')).toHaveLength(1);
+ expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/x/y.png');
+ });
+
+ it('leaves text with no path untouched', () => {
+ const root = linkify('Ratio 3/4 on 2026/08/16, see src/app.ts
');
+
+ expect(paths(root)).toHaveLength(0);
+ expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts');
+ });
+
+ it('cannot turn model text into markup', () => {
+ // The anchor is built with createElement + textContent, so even a
+ // path-shaped payload stays text. (`<` also ends a match, so the linkifier
+ // never spans into it in the first place.)
+ const root = linkify('/tmp/x.png<img src=x onerror=alert(1)>.png
');
+
+ expect(root.querySelector('img')).toBeNull();
+ expect(root.textContent).toContain('
.png');
+ for (const link of paths(root)) {
+ expect(link.innerHTML).toBe(link.textContent);
+ }
+ });
+
+ it('is wired into message rendering and the click delegate', () => {
+ // The linkifier is only reachable through these two call sites; losing
+ // either leaves inert paths (no linkify) or dead links (no handler).
+ expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
+ expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
+ expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
+ });
+});
diff --git a/test/routes/file-routes-attachment-path-guard.test.ts b/test/routes/file-routes-attachment-path-guard.test.ts
index 8a854386..90e1066b 100644
--- a/test/routes/file-routes-attachment-path-guard.test.ts
+++ b/test/routes/file-routes-attachment-path-guard.test.ts
@@ -56,6 +56,7 @@ import {
registerExternalAttachment,
type AttachmentRecord,
} from '../../src/attachment-registry.js';
+import { SseEvent } from '../../src/web/sse-events.js';
const mockedStat = vi.mocked(fs.stat);
const mockedRealpathSync = vi.mocked(realpathSync);
@@ -355,4 +356,51 @@ describe('file-routes attachment path guard (COD-53)', () => {
attachmentRegistry.clearSession('test-session-mlc');
});
});
+
+ // ===== Quiet registration (click-to-preview) =====
+ // The file-preview overlay registers a clicked out-of-workspace path to mint
+ // an id it can render by. It is already putting the file on screen, so the
+ // usual attachment card + unread badge would announce what the user is
+ // looking at. `notify: false` suppresses ONLY the broadcast — the guard, the
+ // registry entry and the by-id routes are identical either way.
+ describe('quiet registration', () => {
+ const outside = '/tmp/claude-1000/scratchpad/probe-run-native.png';
+
+ it('broadcasts by default, so the CLI and publish paths keep their card', async () => {
+ mockedStat.mockResolvedValue({ size: 128, isFile: () => true, mtimeMs: 5 } as never);
+ const res = await harness.app.inject({
+ method: 'POST',
+ url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
+ payload: { path: outside },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(harness.ctx.broadcast).toHaveBeenCalledWith(SseEvent.AttachmentDetected, expect.anything());
+ });
+
+ it('registers and serves a clicked path without broadcasting when notify is false', async () => {
+ const content = Buffer.from('PNGDATA');
+ mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
+ mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
+
+ const res = await harness.app.inject({
+ method: 'POST',
+ url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
+ payload: { path: outside, notify: false },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.fileName).toBe('probe-run-native.png');
+ expect(harness.ctx.broadcast).not.toHaveBeenCalled();
+
+ // The preview renders from this route, so the id has to be live.
+ const rawRes = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
+ });
+ expect(rawRes.statusCode).toBe(200);
+ expect(rawRes.headers['content-type']).toBe('image/png');
+ });
+ });
});