mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39: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 */ }
|
||||
}
|
||||
|
||||
@@ -1306,7 +1306,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Debug: Track if provider is being invoked
|
||||
let lastInvokedLine = -1;
|
||||
|
||||
this.terminal.registerLinkProvider({
|
||||
const provider = {
|
||||
provideLinks(bufferLineNumber, callback) {
|
||||
// Debug logging - only log if line changed to avoid spam
|
||||
if (bufferLineNumber !== lastInvokedLine) {
|
||||
@@ -1523,11 +1523,124 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Keep the provider reachable: on touch devices xterm's linkifier never
|
||||
// resolves a link (it is driven by mousemove/mouseup, which a tap does not
|
||||
// produce), so the tap path asks this SAME provider what is under the finger
|
||||
// rather than growing a second, driftable copy of the patterns.
|
||||
// See _terminalLinkAtPoint.
|
||||
this._terminalLinkProvider = provider;
|
||||
this.terminal.registerLinkProvider(provider);
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
},
|
||||
|
||||
/**
|
||||
* The terminal link under a viewport point, or null.
|
||||
*
|
||||
* Resolved through the provider registered above, 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` — flattened `y * cols + x`, inclusive
|
||||
* at both ends — for the same reason.
|
||||
*
|
||||
* ⚠️ The provider answers its callback SYNCHRONOUSLY (every path in
|
||||
* `registerFilePathLinkProvider` does, including the empty ones). xterm's
|
||||
* ILinkProvider contract permits an async reply, so this reads whatever
|
||||
* arrived by the time the call returns and answers null otherwise: a tap then
|
||||
* keeps its normal meaning instead of opening a link late, after the gesture
|
||||
* that made `window.open` permissible is gone.
|
||||
*/
|
||||
_terminalLinkAtPoint(clientX, clientY) {
|
||||
const provider = this._terminalLinkProvider;
|
||||
const buffer = this.terminal?.buffer?.active;
|
||||
if (!provider || !buffer) return null;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos) return null;
|
||||
// Link ranges are 1-based ABSOLUTE buffer lines (xterm adds ydisp to the
|
||||
// viewport row before asking), which is what the provider's coordAt() emits.
|
||||
const y = (buffer.viewportY || 0) + pos.row;
|
||||
let links = null;
|
||||
try {
|
||||
provider.provideLinks(y, (result) => {
|
||||
links = result || [];
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!links || links.length === 0) return null;
|
||||
const cols = Math.max(1, this.terminal.cols || 1);
|
||||
const current = y * cols + pos.col;
|
||||
return (
|
||||
links.find((link) => {
|
||||
const start = link?.range?.start;
|
||||
const end = link?.range?.end;
|
||||
if (!start || !end) return false;
|
||||
return start.y * cols + start.x <= current && current <= end.y * cols + end.x;
|
||||
}) || null
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Is this point on the caret's logical line — the editable composer?
|
||||
*
|
||||
* There a tap means "put the cursor here", so a URL the USER typed or pasted
|
||||
* into a prompt must stay editable rather than opening itself. The caret is the
|
||||
* signal that works for every CLI: claude's composer row carries it, and in a
|
||||
* plain shell it sits on the prompt line while output scrolls above, so the
|
||||
* same test covers both without asking what mode is running (tap
|
||||
* classification cannot answer this — a shell session classifies EVERY tap as
|
||||
* 'input', which would leave every URL in shell output inert).
|
||||
*
|
||||
* The caret's line is walked out through soft wraps, since a long prompt spans
|
||||
* rows.
|
||||
*/
|
||||
_tapIsOnCaretLine(clientX, clientY) {
|
||||
const buffer = this.terminal?.buffer?.active;
|
||||
if (!buffer?.getLine) return false;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos) return false;
|
||||
const rows = Math.max(1, this.terminal.rows || 1);
|
||||
const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0));
|
||||
const tappedRow = pos.row - 1;
|
||||
if (tappedRow === cursorRow) return true;
|
||||
let start = cursorRow;
|
||||
while (start > 0 && buffer.getLine(buffer.viewportY + start)?.isWrapped) start--;
|
||||
let end = cursorRow;
|
||||
while (end + 1 < rows && buffer.getLine(buffer.viewportY + end + 1)?.isWrapped) end++;
|
||||
return tappedRow >= start && tappedRow <= end;
|
||||
},
|
||||
|
||||
/**
|
||||
* Activate the terminal link under a touch point. Returns true when one was.
|
||||
*
|
||||
* xterm activates a link from a `mousemove` that resolves what is under the
|
||||
* pointer, followed by a `mouseup` on its SCREEN element — and on a touch
|
||||
* device it receives neither: `touch-action: none` plus touchstart's
|
||||
* preventDefault suppress the browser's compatibility mouse events,
|
||||
* _installMobileTapMouseGuard drops the ones that still arrive, and the
|
||||
* synthetic 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, which is unfinishable from a phone without this.
|
||||
*
|
||||
* Activating here, synchronously inside the touchend handler, is what keeps
|
||||
* the user gesture that lets the URL branch's `window.open` through the popup
|
||||
* blocker; a later activation (a timer, a promise) is silently swallowed.
|
||||
*/
|
||||
_activateTerminalLinkAtPoint(clientX, clientY) {
|
||||
const link = this._terminalLinkAtPoint(clientX, clientY);
|
||||
if (!link || typeof link.activate !== 'function') return false;
|
||||
try {
|
||||
link.activate(null, link.text);
|
||||
} catch (err) {
|
||||
console.warn('[LinkProvider] tap activation failed:', err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
showWelcome() {
|
||||
// Phones get the session overview instead of the welcome screen: on a small
|
||||
// screen "which session is blocked on me" beats "how do I start one". The
|
||||
@@ -3705,6 +3818,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
// touchstart already classified this exact point; reuse it rather than paying
|
||||
// a second full-viewport scan for the same gesture.
|
||||
const intent = cachedIntent ?? this._classifyMobileTerminalTap(touch.clientX, touch.clientY);
|
||||
// Computed once and reused by the keyboard decision at the tail of this
|
||||
// method: both ask the same question, and the pane cannot change in between
|
||||
// (a mouse report only reaches the PTY; its output lands on a later turn).
|
||||
const actionable = this._isActionableMobileTerminalTap(touch.clientX, touch.clientY);
|
||||
|
||||
// A tap that lands ON a link activates it, at any scroll position and before
|
||||
// any mouse report — exactly what a desktop click does, where the provider's
|
||||
// activate() runs and _handleDesktopTerminalClick deliberately skips the SGR
|
||||
// tap for a hovered link so the CLI never also sees a click there.
|
||||
//
|
||||
// Two kinds of row keep their existing meaning instead: the composer, where a
|
||||
// tap places the caret in text the USER typed (_tapIsOnCaretLine), 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 — on a phone the dialog is the only interaction that
|
||||
// matters, so it wins.
|
||||
if (
|
||||
!actionable &&
|
||||
!this._tapIsOnCaretLine(touch.clientX, touch.clientY) &&
|
||||
this._activateTerminalLinkAtPoint(touch.clientX, touch.clientY)
|
||||
) {
|
||||
// No focus change: a 'content' tap was already blurred by touchstart, and
|
||||
// popping the keyboard behind a tab that is about to take over is noise.
|
||||
return 'link';
|
||||
}
|
||||
|
||||
if (intent === 'history') {
|
||||
// Scrolled up: send NO mouse report — a tap on old output must not be
|
||||
// delivered to the CLI as a click on whatever row now occupies that cell.
|
||||
@@ -3728,7 +3867,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
if (intent === 'content' && this._isActionableMobileTerminalTap(touch.clientX, touch.clientY)) {
|
||||
if (intent === 'content' && actionable) {
|
||||
// A synthetic xterm click can focus its helper textarea. Blur after the
|
||||
// report so collapsing a readback never opens or retains the keyboard.
|
||||
this._blurMobileTerminalInput();
|
||||
|
||||
Reference in New Issue
Block a user