` 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 56f3852a5..0e5e0b6ed 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 fbec74645..2750e94d8 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -15,6 +15,11 @@
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
+// Bounds for the by-id text preview, mirroring what the workspace text preview
+// already does server-side (500 lines). The byte cap rides a Range request, so
+// a huge log is a partial read rather than a download the viewer throws away.
+const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
+const TEXT_PREVIEW_MAX_LINES = 500;
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -3234,6 +3239,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, video, audio, PDF, Office documents and text files 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,25 +3322,70 @@ 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 = `${escapeHtml(externalError)}
`;
+ 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();
+ const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
+ const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
+ // 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 (VIDEO_EXTS.has(ext)) {
+ // Same markup as the workspace branch below, including playsinline: iOS
+ // otherwise hijacks playback into its own fullscreen player, which
+ // leaves this overlay behind it with no way back but its close button.
+ // The attachment raw route is range-aware, so the scrub bar works.
+ bodyEl.innerHTML = ``;
+ } else if (AUDIO_EXTS.has(ext)) {
+ bodyEl.innerHTML = ``;
} else if (ext === 'pdf') {
bodyEl.innerHTML = ``;
} else if (ext === 'docx' || ext === 'pptx') {
bodyEl.innerHTML = ``;
} else {
try {
- const res = await fetch(`${base}/raw`);
+ // Bounded like the workspace text preview: a Range for the first
+ // chunk (the route is range-aware, so this is a real partial read,
+ // not a 50MB download thrown away) and a line cap on top. An agent's
+ // log can be enormous, and rendering all of it into one is how
+ // you lock up the tab on the file you wanted to glance at.
+ const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
- bodyEl.innerHTML = `${escapeHtml(text)}
`;
+ const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
+ const lines = text.split('\n');
+ const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
+ const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
+ bodyEl.innerHTML = `${escapeHtml(shown)}
`;
+ this.filePreviewContent = shown;
+ if (clippedByLines || clippedByBytes) {
+ const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
+ footerEl.textContent = `${footerEl.textContent} (${note})`;
+ }
} catch (err) {
bodyEl.innerHTML = `Error: ${escapeHtml(err.message)}
`;
}
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 5df82884a..0b9b8395b 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 2c36011a7..e119f03d4 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,19 @@ 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.
+ //
+ // Text goes to the log viewer, which follows a file that is still
+ // being written — but ONLY where it can actually read: it spawns
+ // `tail -f` and allows the workspace, /var/log and ~/logs, so an
+ // out-of-workspace path there answered "Path must be within
+ // working directory or allowed log directories" while the SAME
+ // path clicked in the response viewer previewed fine. The preview
+ // reads those through the guarded attachment routes, so external
+ // paths route there and the two surfaces agree.
+ if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
self.openFilePreview(text, self.activeSessionId);
return;
}
diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts
index ede34a58e..ccc4108ee 100644
--- a/src/web/routes/file-routes.ts
+++ b/src/web/routes/file-routes.ts
@@ -23,12 +23,15 @@ import type {
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
+ AUDIO_ATTACHMENT_EXTENSIONS,
AttachmentRegistrationError,
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
isSupportedAttachmentExtension,
registerExternalAttachment,
+ TEXT_ATTACHMENT_EXTENSIONS,
+ VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
@@ -67,6 +70,22 @@ const MIME_TYPES: Record = {
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
+ // Media needs a real type, not the octet-stream fallback: a