web: clickable links in terminal rows and dashboard messages (issue #233)

This commit is contained in:
iris 2026-05-22 01:06:23 +02:00
parent 4b51c198d5
commit 15e44955a8
5 changed files with 82 additions and 8 deletions

View file

@ -18,7 +18,11 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed
this terminal — sticky-bottom auto-scroll, "↓ N new" pill, this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
history backfill, SSE plumbing all live there. Each page history backfill, SSE plumbing all live there. Each page
registers a kind→renderer map; unknown kinds fall through to registers a kind→renderer map; unknown kinds fall through to
a JSON-dump note row. a JSON-dump note row. Bare `http(s)://` URLs in row text are
turned into clickable new-tab links by `HiveTerminal.linkify`
(text-node based, no `innerHTML` — XSS-safe); markdown bodies
get the same treatment via `marked`'s autolink, with the
rendered `<a>`s rewritten to `target="_blank"` (issue #233).
- `GET /api/state` → JSON snapshot the JS app renders into the - `GET /api/state` → JSON snapshot the JS app renders into the
DOM. Includes a top-level `seq` (the dashboard event channel's DOM. Includes a top-level `seq` (the dashboard event channel's
high-water mark at the moment the snapshot was assembled); high-water mark at the moment the snapshot was assembled);

View file

@ -755,6 +755,12 @@
try { try {
marked.setOptions({ breaks: true, gfm: true }); marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = marked.parse(src); div.innerHTML = marked.parse(src);
// marked autolinks URLs but leaves them same-tab — open them
// externally so a click never unloads the terminal. (issue #233)
div.querySelectorAll('a[href]').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
} catch (err) { } catch (err) {
console.warn('marked failed', err); console.warn('marked failed', err);
div.textContent = src; div.textContent = src;

View file

@ -134,6 +134,12 @@
if (window.marked && typeof window.marked.parse === 'function') { if (window.marked && typeof window.marked.parse === 'function') {
marked.setOptions({ breaks: true, gfm: true }); marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = marked.parse(text); div.innerHTML = marked.parse(text);
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates away from the dashboard. (issue #233)
div.querySelectorAll('a[href]').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
} else { } else {
div.textContent = text; div.textContent = text;
} }
@ -192,12 +198,23 @@
// not in `refs` stays plain text. No client-side regex, no probe // not in `refs` stays plain text. No client-side regex, no probe
// endpoint — the server saw the body first and made the call. When // endpoint — the server saw the body first and made the call. When
// `refs` is empty/missing we just emit plain text. // `refs` is empty/missing we just emit plain text.
// Append a plain-text run, with bare http(s) URLs turned into clickable
// links via the shared terminal linkifier. Falls back to a plain text
// node if the terminal module hasn't loaded. (issue #233)
function appendText(parent, s) {
if (!s) return;
if (window.HiveTerminal && typeof HiveTerminal.linkify === 'function') {
parent.appendChild(HiveTerminal.linkify(s));
} else {
parent.appendChild(document.createTextNode(s));
}
}
function appendLinkified(parent, text, refs) { function appendLinkified(parent, text, refs) {
if (text == null) return; if (text == null) return;
const str = String(text); const str = String(text);
const tokens = (refs || []).slice(); const tokens = (refs || []).slice();
if (!tokens.length) { if (!tokens.length) {
if (str) parent.appendChild(document.createTextNode(str)); appendText(parent, str);
return; return;
} }
// Walk the string left-to-right, at each step looking for the // Walk the string left-to-right, at each step looking for the
@ -220,11 +237,11 @@
} }
} }
if (bestStart === -1) { if (bestStart === -1) {
parent.appendChild(document.createTextNode(str.slice(i))); appendText(parent, str.slice(i));
break; break;
} }
if (bestStart > i) { if (bestStart > i) {
parent.appendChild(document.createTextNode(str.slice(i, bestStart))); appendText(parent, str.slice(i, bestStart));
} }
parent.appendChild(makePathLink(bestToken)); parent.appendChild(makePathLink(bestToken));
i = bestStart + bestToken.length; i = bestStart + bestToken.length;

View file

@ -213,6 +213,9 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); }
border-radius: 0; border-radius: 0;
} }
.live .row .md a { color: var(--cyan); text-decoration: underline; } .live .row .md a { color: var(--cyan); text-decoration: underline; }
/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
.live .row a { color: var(--cyan); text-decoration: underline; }
.live .row a:hover { color: var(--fg); }
.live .row .md strong { color: inherit; font-weight: bold; } .live .row .md strong { color: inherit; font-weight: bold; }
.live .row .md em { color: inherit; font-style: italic; } .live .row .md em { color: inherit; font-style: italic; }
.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; } .live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }

View file

@ -113,7 +113,7 @@
clearPlaceholder(); clearPlaceholder();
const e = document.createElement('div'); const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
e.textContent = text; e.appendChild(linkify(text));
log.appendChild(e); log.appendChild(e);
afterAppend(); afterAppend();
return e; return e;
@ -127,7 +127,7 @@
d.appendChild(s); d.appendChild(s);
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.className = 'tool-body'; pre.className = 'tool-body';
pre.textContent = body; pre.appendChild(linkify(body));
d.appendChild(pre); d.appendChild(pre);
log.appendChild(d); log.appendChild(d);
afterAppend(); afterAppend();
@ -158,7 +158,7 @@
function api(extra) { function api(extra) {
return Object.assign({ return Object.assign({
row, details, detailsDiff, placeholder, row, details, detailsDiff, placeholder, linkify,
fromHistory: false, fromHistory: false,
}, extra || {}); }, extra || {});
} }
@ -296,5 +296,49 @@
return { row, details, detailsDiff, placeholder, ready }; return { row, details, detailsDiff, placeholder, ready };
} }
window.HiveTerminal = { create }; // Build a DocumentFragment from `text`, turning bare http(s) URLs into
// clickable links that open in a new tab. Non-URL text stays as plain
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
// punctuation is kept out of the link. (issue #233)
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
function linkify(text) {
const str = text == null ? '' : String(text);
const frag = document.createDocumentFragment();
if (str.indexOf('://') === -1) { // fast path: no URLs
if (str) frag.appendChild(document.createTextNode(str));
return frag;
}
let last = 0;
let m;
LINKIFY_URL_RE.lastIndex = 0;
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
let url = m[0];
// Don't swallow trailing punctuation that's really sentence text.
const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : '';
if (tail) url = url.slice(0, -tail.length);
if (m.index > last) {
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
}
if (!url.slice(url.indexOf('://') + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim.
frag.appendChild(document.createTextNode(m[0]));
} else {
const a = document.createElement('a');
a.href = url; // regex only matches https?:// — safe
a.textContent = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
frag.appendChild(a);
if (tail) frag.appendChild(document.createTextNode(tail));
}
last = m.index + m[0].length;
}
if (last < str.length) {
frag.appendChild(document.createTextNode(str.slice(last)));
}
return frag;
}
window.HiveTerminal = { create, linkify };
})(); })();