From 6f785e116f422e791b4fe1f255a9c066fdb59b66 Mon Sep 17 00:00:00 2001 From: JD Date: Sun, 18 Oct 2026 13:36:10 -0400 Subject: [PATCH] fix(response-viewer): open markdown links to file paths in the preview, not as a same-origin URL Codex cites files as [label](/abs/path). The chat view gave those links target=_blank, so a click opened /abs/path and landed on a Fastify 404. Path-shaped hrefs now take the linkified-path shape the file preview already opens; scheme, protocol-relative and fragment links are unchanged. --- docs/wiki/Mobile-Guide.md | 5 +- docs/wiki/Working-With-Files.md | 3 +- src/web/public/app.js | 28 ++++++++++ test/response-viewer-file-links.test.ts | 71 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 11a782922..820710ad7 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -134,8 +134,9 @@ already writing in the editor. - **Tap a link** in terminal output and it opens in a new tab. Same for a 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 and the file-preview overlay opens (Markdown rendered, - HTML as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up +- **Tap a file path** an agent printed, or a link to one in its answer (Codex writes + `[report](/tmp/report.json)`), and the file-preview overlay opens (Markdown rendered, HTML + as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up transcript too. - A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's numbered choice still answers the dialog even when the row contains a path — the dialog wins, diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index ef7c5697e..df94e8d68 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -84,7 +84,8 @@ File paths in a session are links. That works in two places: - **In the terminal**, on any absolute path an agent prints. - **In the response viewer**, where paths are usually written as prose or in backticks. They - render as underlined monospace links. + render as underlined monospace links. A Markdown link whose target is a path + (`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way. Clicking one opens it in the preview: images and PDFs render, video and audio play with a working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a diff --git a/src/web/public/app.js b/src/web/public/app.js index 627164971..6d53cb4d0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2966,6 +2966,34 @@ class CodemanApp { // out of the middle of rendering a message. if (typeof findFilePathLinks !== 'function') return; + // A markdown link whose destination is a path (`[report](/home/me/run/x.json)`, + // Codex's usual way of citing a file) resolved against Codeman's own origin + // and opened a 404 in a new tab. Give it the linkified-path shape instead; + // links with a scheme or `//host` keep their own behaviour. + for (const a of root.querySelectorAll('a[href]')) { + // The URL parser drops tabs and newlines, so `http ://` is still http. + const href = (a.getAttribute('href') || '').replace(/[\t\n\r]/g, ''); + if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) continue; + // `#L42`, `?plain=1` and a `:42:7` suffix name a spot in the file, not the + // file. The suffix is cut before decoding, so an encoded `%3A` stays a name. + let path = href.split('#')[0].split('?')[0].replace(/:\d+(?::\d+)?$/, ''); + try { + path = decodeURIComponent(path); + } catch { + /* malformed escape: keep the path as written */ + } + // A bare `#fragment` (and every `.rv-path`, whose href is `#`) ends up empty. + if (!path) continue; + a.className = 'rv-path'; + a.dataset.path = path; + a.dataset.linkified = '1'; + if (path.endsWith('/') || findFilePathLinks(path).some((f) => f.folder && f.path === path)) a.dataset.folder = '1'; + a.title = path; + a.setAttribute('href', '#'); + a.removeAttribute('target'); + a.removeAttribute('rel'); + } + // Collect first: replacing a node while the walker is positioned on it // invalidates the traversal. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index a771ad7cc..c30d2fd38 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -110,6 +110,77 @@ describe('response viewer file-path linkifier', () => { expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.png'); }); + it('turns a markdown link to a path into a preview link, not a same-origin navigation', () => { + // Codex cites files as `[label](/abs/path)`: left alone, the href resolved + // against Codeman's origin and opened a 404 JSON page in a new tab. + const path = '/home/me/llm-bench/runs/crash-20261017/incident-summary.json'; + const root = linkify( + `

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

` + ); + + const links = paths(root); + expect(links.map((a) => a.getAttribute('data-path'))).toEqual([ + path, + 'src/a b.ts', + 'docs/', + '/tmp/renders', + // An encoded colon is part of the name, never a line suffix. + '/tmp/release:12', + ]); + for (const a of links) { + expect(a.getAttribute('href')).toBe('#'); + expect(a.hasAttribute('target')).toBe(false); + expect(a.hasAttribute('rel')).toBe(false); + expect(a.getAttribute('data-linkified')).toBe('1'); + } + expect(links[0].textContent).toBe('Our incident record'); + // 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]); + }); + + it('opens a clicked markdown path link in the file preview through the click delegate', () => { + const app = Object.create(CodemanApp.prototype) as { + activeSessionId: string; + openFilePreview: ReturnType; + openFolderGrid: ReturnType; + _linkifyFilePaths(root: unknown): void; + _bindResponseViewerInteractions(body: unknown): void; + }; + app.activeSessionId = 'sess-1'; + app.openFilePreview = vi.fn(); + app.openFolderGrid = vi.fn(); + const body = document.createElement('div'); + body.innerHTML = '

Our incident record

'; + app._linkifyFilePaths(body); + app._bindResponseViewerInteractions(body); + + const click = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }); + body.querySelector('a')!.dispatchEvent(click); + + expect(click.defaultPrevented).toBe(true); + expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/run/summary.json', 'sess-1'); + expect(app.openFolderGrid).not.toHaveBeenCalled(); + }); + + it('keeps web, mail, protocol-relative and fragment links as they are', () => { + const hrefs = ['https://example.com/a.json', 'mailto:me@example.com', '//cdn.example.com/x.js', '#section']; + const root = linkify(`

${hrefs.map((h) => `x`).join(' ')}

`); + + expect(paths(root)).toHaveLength(0); + expect(Array.from(root.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual(hrefs); + }); + + it('reads a scheme split by a tab as the URL the browser would open', () => { + // The URL parser drops tabs, so this is http://localhost:5173/a, never a relative file. + const root = linkify('

local

'); + + expect(paths(root)).toHaveLength(0); + expect(root.querySelector('a')!.getAttribute('href')).toBe('http\t://localhost:5173/a'); + }); + it('leaves text with no path untouched', () => { const root = linkify('

Ratio 3/4 on 2026/08/16, see the app

');