diff --git a/src/web/public/app.js b/src/web/public/app.js
index 62d98ce20..f7cefd1ef 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -2857,7 +2857,9 @@ class CodemanApp {
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
- body.addEventListener('click', async (ev) => {
+ // The two link kinds whose href is a placeholder the browser cannot open
+ // (`#`, `#fragment`). Returns true when it took the event.
+ const openPlaceholderLink = (ev) => {
// File path (_linkifyFilePaths): open it in the preview overlay, which
// resolves workspace and out-of-workspace paths alike.
const pathLink = ev.target.closest('a.rv-path');
@@ -2874,7 +2876,7 @@ class CodemanApp {
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
else if (filePath) this.openFilePreview(target || filePath, sessionId);
- return;
+ return true;
}
// An in-document link (`[Install](#installation)`). The browser must not follow it: with
@@ -2891,8 +2893,18 @@ class CodemanApp {
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
}
- return;
+ return true;
}
+ return false;
+ };
+ // A middle click fires auxclick, never click, so the browser followed the
+ // placeholder and opened the dashboard root in a new tab: do what a click
+ // does instead. A real URL keeps the browser's own middle click.
+ body.addEventListener('auxclick', (ev) => {
+ if (ev.button === 1) openPlaceholderLink(ev);
+ });
+ body.addEventListener('click', async (ev) => {
+ if (openPlaceholderLink(ev)) return;
// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
diff --git a/test/response-viewer-middle-click.test.ts b/test/response-viewer-middle-click.test.ts
new file mode 100644
index 000000000..b2d1c18df
--- /dev/null
+++ b/test/response-viewer-middle-click.test.ts
@@ -0,0 +1,262 @@
+/**
+ * @fileoverview A middle click on a placeholder link in rendered markdown
+ * (`CodemanApp._bindResponseViewerInteractions`).
+ *
+ * File-path links (`a.rv-path`) and in-document links carry `href="#"` /
+ * `href="#frag"`: placeholders the click delegate resolves itself. A middle
+ * click fires `auxclick`, never `click`, so the browser used to follow the
+ * placeholder and open the dashboard root (`
wrote src/app.ts
'); + const link = body.querySelector('a.rv-path')!; + expect(link.getAttribute('href')).toBe('#'); + + const ev = press(link, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + expect(app.openFolderGrid).not.toHaveBeenCalled(); + expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled(); + }); + + it('opens a markdown link whose destination is a path, like a click does', () => { + const body = responseViewer(''); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/run/summary.json', 'sess-1']]); + }); + + it('opens a folder path in the folder grid', () => { + const body = responseViewer(''); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFolderGrid.mock.calls).toEqual([['/tmp/renders/', 'sess-1']]); + expect(app.openFilePreview).not.toHaveBeenCalled(); + }); + + it('does the same with Ctrl held, as Ctrl+click does', () => { + const body = responseViewer('wrote src/app.ts
'); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1, ctrlKey: true }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + }); + + it.each([ + ['right', 2], + ['back', 3], + ['forward', 4], + ])('ignores the %s button', (_name, button) => { + const body = responseViewer('wrote src/app.ts
'); + + const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button }); + + expect(ev.defaultPrevented).toBe(false); + noneCalled(); + }); + + it('opens once per gesture: a left click still goes through click alone', () => { + const body = responseViewer('wrote src/app.ts
'); + // Binding is idempotent: a second bind must not add a second auxclick listener. + app._bindResponseViewerInteractions(body); + const link = body.querySelector('a.rv-path')!; + + press(link, 'auxclick', { button: 1 }); + expect(app.openFilePreview).toHaveBeenCalledTimes(1); + + app.openFilePreview.mockClear(); + const click = press(link, 'click', { button: 0 }); + expect(click.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + }); +}); + +describe('middle click on an in-document link', () => { + it('scrolls to the heading in place instead of following the fragment', () => { + const body = responseViewer('see install
'); + const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnTypesee install
'); + const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnTypewrote src/app.ts
'); + + const ev = press(body.querySelector('a.rv-path')!, 'click', { button: 0 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]); + expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled(); + }); + + it('hands a loopback URL to the web-tab hook', () => { + const body = responseViewer( + '' + ); + + const ev = press(body.querySelector('a')!, 'click', { button: 0 }); + + expect(ev.defaultPrevented).toBe(true); + expect(app.openLinkThroughWebTabIfLoopback.mock.calls).toEqual([['http://localhost:5173/']]); + expect(app.openFilePreview).not.toHaveBeenCalled(); + }); +});