From 15e44955a813f91763e81ff65f5bd6b89fcf9bc3 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 22 May 2026 01:06:23 +0200 Subject: [PATCH] web: clickable links in terminal rows and dashboard messages (issue #233) --- docs/web-ui.md | 6 +++- hive-ag3nt/assets/app.js | 6 ++++ hive-c0re/assets/app.js | 23 +++++++++++++-- hive-fr0nt/assets/terminal.css | 3 ++ hive-fr0nt/assets/terminal.js | 52 +++++++++++++++++++++++++++++++--- 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 1fa6331..b937c50 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -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, history backfill, SSE plumbing all live there. Each page 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 ``s rewritten to `target="_blank"` (issue #233). - `GET /api/state` → JSON snapshot the JS app renders into the DOM. Includes a top-level `seq` (the dashboard event channel's high-water mark at the moment the snapshot was assembled); diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 2ff419a..ec712a8 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -755,6 +755,12 @@ try { marked.setOptions({ breaks: true, gfm: true }); 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) { console.warn('marked failed', err); div.textContent = src; diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 44785ac..27b7834 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -134,6 +134,12 @@ if (window.marked && typeof window.marked.parse === 'function') { marked.setOptions({ breaks: true, gfm: true }); 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 { div.textContent = text; } @@ -192,12 +198,23 @@ // not in `refs` stays plain text. No client-side regex, no probe // endpoint — the server saw the body first and made the call. When // `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) { if (text == null) return; const str = String(text); const tokens = (refs || []).slice(); if (!tokens.length) { - if (str) parent.appendChild(document.createTextNode(str)); + appendText(parent, str); return; } // Walk the string left-to-right, at each step looking for the @@ -220,11 +237,11 @@ } } if (bestStart === -1) { - parent.appendChild(document.createTextNode(str.slice(i))); + appendText(parent, str.slice(i)); break; } if (bestStart > i) { - parent.appendChild(document.createTextNode(str.slice(i, bestStart))); + appendText(parent, str.slice(i, bestStart)); } parent.appendChild(makePathLink(bestToken)); i = bestStart + bestToken.length; diff --git a/hive-fr0nt/assets/terminal.css b/hive-fr0nt/assets/terminal.css index 6b173e3..b28449a 100644 --- a/hive-fr0nt/assets/terminal.css +++ b/hive-fr0nt/assets/terminal.css @@ -213,6 +213,9 @@ details.row > pre.diff-body .diff-ctx { color: var(--fg); } border-radius: 0; } .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 em { color: inherit; font-style: italic; } .live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; } diff --git a/hive-fr0nt/assets/terminal.js b/hive-fr0nt/assets/terminal.js index 0d9b0cd..fd30ea2 100644 --- a/hive-fr0nt/assets/terminal.js +++ b/hive-fr0nt/assets/terminal.js @@ -113,7 +113,7 @@ clearPlaceholder(); const e = document.createElement('div'); e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - e.textContent = text; + e.appendChild(linkify(text)); log.appendChild(e); afterAppend(); return e; @@ -127,7 +127,7 @@ d.appendChild(s); const pre = document.createElement('pre'); pre.className = 'tool-body'; - pre.textContent = body; + pre.appendChild(linkify(body)); d.appendChild(pre); log.appendChild(d); afterAppend(); @@ -158,7 +158,7 @@ function api(extra) { return Object.assign({ - row, details, detailsDiff, placeholder, + row, details, detailsDiff, placeholder, linkify, fromHistory: false, }, extra || {}); } @@ -296,5 +296,49 @@ 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 }; })();