Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/wiki/Mobile-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion docs/wiki/Working-With-Files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions test/response-viewer-file-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<p><a href="${path}" target="_blank" rel="noopener noreferrer">Our incident record</a> and ` +
`<a href="src/a%20b.ts:42:7">a b</a>, <a href="docs/#L3">docs</a>, <a href="/tmp/renders">renders</a>, ` +
`<a href="/tmp/release%3A12">release</a></p>`
);

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<typeof vi.fn>;
openFolderGrid: ReturnType<typeof vi.fn>;
_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 = '<p><a href="/home/me/run/summary.json" target="_blank">Our incident record</a></p>';
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(`<p>${hrefs.map((h) => `<a href="${h}">x</a>`).join(' ')}</p>`);

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('<p><a href="http&#9;://localhost:5173/a">local</a></p>');

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('<p>Ratio 3/4 on 2026/08/16, see the app</p>');

Expand Down
Loading