mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
fix(mobile): links open in a new tab from a tap, in the terminal and the chat
On a phone no link was openable, on either surface, for two unrelated reasons. **Terminal.** xterm resolves the link under the pointer on `mousemove` and activates it on `mouseup` over its SCREEN element. A touch tap delivers neither: `touch-action: none` on the terminal subtree plus touchstart's preventDefault for a 'content' tap suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive inside the 450ms tap window, and the synthetic mousedown/mouseup pair dispatched for mouse REPORTING goes to the `.xterm` root — an ancestor of the node the linkifier listens on, so it cannot reach it — and carries no mousemove either way. Every URL and file path in the terminal was therefore inert on phones and tablets, Claude Code's own `/login` URL included. The tap path now activates the link itself, through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`), so a tap and a desktop click can never disagree about what is a link or where it ends — containment mirrors xterm's own `_linkAtPosition`. It runs synchronously inside the touchend handler, which is what keeps the user gesture that lets `window.open` past the popup blocker, and before any mouse report, exactly as `_handleDesktopTerminalClick` already skips the SGR tap for a hovered link. Two kinds of row keep their existing meaning: the caret's logical line, where a tap places the cursor and a URL the user typed must stay editable, and TUI-owned rows, where a numbered choice or an expandable readback is answering a dialog and routinely carries the very path the tap would otherwise open. The caret line is the boundary rather than the tap intent, because a plain shell classifies EVERY tap as 'input' and gating on that would leave every URL in shell output inert. **Chat.** `marked` emits a bare `<a href>` and the markdown sanitizer's allowlist carries no `target`, so a tap in the response viewer navigated the current tab away: on a phone that unloads the whole dashboard — SSE, terminal buffers, unsent composer text — and there is no middle-click or open-in-new-tab affordance to work around it. `_renderMarkdown` now decorates anchors in the template pass it already makes for code blocks. That pass runs AFTER sanitizing, so it is the only source of both attributes: an agent-authored `target`/`rel` is already stripped, and `rel="noopener noreferrer"` is set on the same element in the same breath, so no page Codeman opens gets a `window.opener` handle back. Fragment links stay in-page; mailto:/tel: are left to the OS rather than stranding an empty tab. Tests: 10 cases in `terminal-touch-tap.test.ts` (URL, file path, log path, scrollback, no-double-report, composer, shell mode, dialog row, no provider) and a new `response-viewer-external-links.test.ts` driving the shipped marked + DOMPurify + app.js. 7 of them fail without the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d4ccff07ca
commit
f2d3a7e3c1
@@ -2053,6 +2053,28 @@ class CodemanApp {
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(pre);
|
||||
});
|
||||
// Links open in a NEW tab.
|
||||
//
|
||||
// marked emits a bare `<a href>` and the sanitizer's allowlist has no
|
||||
// `target`, so a tap in the chat NAVIGATED THE APP AWAY: on a phone that
|
||||
// unloads the whole dashboard — SSE, terminal buffers, unsent composer
|
||||
// text — and the OS back gesture reloads it from scratch, which is what
|
||||
// "links don't open" reads as on mobile, with no middle-click or
|
||||
// open-in-new-tab affordance to work around it.
|
||||
//
|
||||
// This pass runs AFTER sanitizing, so it is the only source of these two
|
||||
// attributes: whatever an agent wrote is already gone, and `rel` is set on
|
||||
// the same element in the same breath, so no page Codeman opens ever gets
|
||||
// a `window.opener` handle back (reverse tabnabbing).
|
||||
//
|
||||
// A fragment link stays in-page, and mailto:/tel: are handed to the OS —
|
||||
// giving those a target just strands an empty tab.
|
||||
tmpl.content.querySelectorAll('a[href]').forEach((a) => {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('#') || /^(?:mailto|tel):/i.test(href)) return;
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
return tmpl.innerHTML;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user