Without a viewport meta tag, some browsers (notably Firefox with Fingerprinting Protection active) internally read screen.availWidth / screen.availHeight to compute the default viewport size, producing the console warning 'Fingerprinting Protection is altering screen.availWidth and screen.availHeight'. Fix: - Add <meta name="viewport" content="width=device-width, initial-scale=1"> to all five pages that were missing it (dashboard index/flow/logs, agent index/stats). screen.html already had it. - Replace window.innerWidth with document.documentElement.clientWidth in the side-panel drag-resize code in common.js. clientWidth returns the actual CSS layout viewport width and is not rounded by Firefox's Fingerprinting Protection, making the drag calculation correct even with privacy.resistFingerprinting enabled.
626 lines
25 KiB
JavaScript
626 lines
25 KiB
JavaScript
// Shared dashboard helpers used by both index.html (./tabs.js) and
|
|
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
|
|
// the OS-notification module, and the path-link / file-preview
|
|
// infrastructure for the side panel.
|
|
|
|
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
|
|
|
// ─── helpers ────────────────────────────────────────────────────────────
|
|
export const $ = (id) => document.getElementById(id);
|
|
|
|
export const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m`
|
|
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`;
|
|
|
|
export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
|
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
|
);
|
|
|
|
export const el = (tag, attrs = {}, ...children) => {
|
|
const e = document.createElement(tag);
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
if (k === 'class') e.className = v;
|
|
else if (k === 'html') e.innerHTML = v;
|
|
else if (k.startsWith('data-')) e.setAttribute(k, v);
|
|
else e.setAttribute(k, v);
|
|
}
|
|
for (const c of children) {
|
|
if (c == null) continue;
|
|
e.append(c.nodeType ? c : document.createTextNode(c));
|
|
}
|
|
return e;
|
|
};
|
|
|
|
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
|
|
const f = el('form', {
|
|
method: 'POST', action, class: 'inline', 'data-async': '',
|
|
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
|
|
// Endpoints whose mutation fires a DashboardEvent (and whose
|
|
// derived store applies it live) opt out of the post-submit
|
|
// /api/state refetch. See the async-form handler.
|
|
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
|
|
});
|
|
for (const [name, value] of Object.entries(extra)) {
|
|
f.append(el('input', { type: 'hidden', name, value }));
|
|
}
|
|
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
|
|
return f;
|
|
};
|
|
|
|
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in tabs.js
|
|
// for now — each has display-specific phrasing ("X running", "X ago")
|
|
// tied to its caller, so they don't generalise cleanly. We can lift
|
|
// them when a second consumer needs the same shape.
|
|
|
|
// ─── shared-worker SSE pipe ─────────────────────────────────────────────
|
|
// Returns an EventSource-shaped facade backed by a SharedWorker that
|
|
// holds one upstream `new EventSource(url)` and fans events out to
|
|
// every connected tab. See docs/web-ui.md (SSE multiplexing paragraph)
|
|
// for the design + Firefox throttling motivation; graceful fallback to
|
|
// direct EventSource on environments without SharedWorker.
|
|
//
|
|
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
|
|
// drops the subscription (the worker closes the upstream when the last
|
|
// subscriber leaves).
|
|
const SHARED_WORKER_PATH = '/static/stream-worker.js';
|
|
const SHARED_WORKER_NAME = 'hyperhive-stream';
|
|
|
|
// One SharedWorker port per page, reused by all openStream calls on
|
|
// that page. Invalidated on `pagehide` so a bfcache restore picks up
|
|
// a fresh port — the cached port may be dead if all other tabs
|
|
// closed while this page was frozen.
|
|
let _sharedPort = null;
|
|
function makeSharedPort() {
|
|
if (typeof SharedWorker === 'undefined') return null;
|
|
try {
|
|
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
|
|
sw.port.start();
|
|
return sw.port;
|
|
} catch (err) {
|
|
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err);
|
|
return null;
|
|
}
|
|
}
|
|
function getSharedPort() {
|
|
if (!_sharedPort) _sharedPort = makeSharedPort();
|
|
return _sharedPort;
|
|
}
|
|
|
|
// SharedWorker death detection: pings from the worker bump the
|
|
// activity clock; a visibility-gated watchdog polls and re-subscribes
|
|
// on a fresh port if the page has been silent past the threshold.
|
|
// See docs/web-ui.md (Worker-death self-heal paragraph) for the
|
|
// timing rationale + Firefox reclaim symptom.
|
|
const WORKER_DEAD_THRESHOLD_MS = 90_000;
|
|
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
|
|
let _lastWorkerActivityAt = 0;
|
|
function noteWorkerActivity() { _lastWorkerActivityAt = Date.now(); }
|
|
let _watchdogTimer = null;
|
|
function startWorkerWatchdog() {
|
|
if (_watchdogTimer != null) return;
|
|
_watchdogTimer = setInterval(() => {
|
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
if (!_activeSubs.size) return;
|
|
if (!_sharedPort) return;
|
|
const sinceLast = Date.now() - _lastWorkerActivityAt;
|
|
if (sinceLast < WORKER_DEAD_THRESHOLD_MS) return;
|
|
console.warn(
|
|
'hyperhive-stream worker silent for ' + Math.round(sinceLast / 1000)
|
|
+ 's, presumed dead — re-subscribing on a fresh port');
|
|
rebindOnFreshPort();
|
|
}, WORKER_WATCHDOG_INTERVAL_MS);
|
|
}
|
|
function rebindOnFreshPort() {
|
|
// Drop any listeners attached to the dead port (best-effort; calls
|
|
// on a dead port throw, which we swallow). The active-subs registry
|
|
// is the source of truth for what we need to re-attach.
|
|
if (_sharedPort) {
|
|
for (const sub of _activeSubs.values()) {
|
|
try { _sharedPort.removeEventListener('message', sub.route); } catch {}
|
|
}
|
|
}
|
|
_sharedPort = null;
|
|
const port = getSharedPort();
|
|
if (!port) return; // SharedWorker unsupported / unavailable — nothing to do
|
|
for (const [url, sub] of _activeSubs) {
|
|
sub.target.readyState = 0; // CONNECTING — worker will (re-)fire 'open'
|
|
port.addEventListener('message', sub.route);
|
|
try { port.postMessage({ kind: 'subscribe', url }); } catch {}
|
|
}
|
|
// Reset the activity clock so the watchdog gives the fresh worker
|
|
// a full window to settle before re-triggering.
|
|
noteWorkerActivity();
|
|
}
|
|
|
|
// Registry of live subscriptions on this page. Keyed by url; entries
|
|
// cache the route function so bfcache-restore re-bind can re-attach
|
|
// it to the fresh port. Today's pages only call openStream once with
|
|
// one URL; the registry shape just keeps the bfcache path correct
|
|
// if that changes.
|
|
const _activeSubs = new Map();
|
|
|
|
// One-shot wiring of page-wide lifecycle hooks. On bfcache freeze
|
|
// we unsubscribe so the worker can close the upstream when the last
|
|
// live subscriber leaves; on bfcache restore we invalidate the cached
|
|
// port (may be dead after the freeze) and re-attach every active
|
|
// subscription to a fresh port. Without this, the consumer's
|
|
// onmessage stays bound but no events flow after restore.
|
|
let _lifecycleBound = false;
|
|
function bindLifecycleOnce() {
|
|
if (_lifecycleBound) return;
|
|
_lifecycleBound = true;
|
|
startWorkerWatchdog();
|
|
window.addEventListener('pagehide', () => {
|
|
if (!_sharedPort) return;
|
|
for (const url of _activeSubs.keys()) {
|
|
try { _sharedPort.postMessage({ kind: 'unsubscribe', url }); }
|
|
catch { /* port dead — worker side already cleaned up */ }
|
|
}
|
|
// Drop port routes too; the bfcache-restore path will re-add
|
|
// them on a fresh port. Leaving stale routes on a dead port
|
|
// would just keep a closure alive without cost, but cleaning
|
|
// up keeps the registry shape honest.
|
|
for (const sub of _activeSubs.values()) {
|
|
try { _sharedPort.removeEventListener('message', sub.route); }
|
|
catch { /* same */ }
|
|
}
|
|
_sharedPort = null;
|
|
});
|
|
window.addEventListener('pageshow', (ev) => {
|
|
if (!ev.persisted) return; // cold load — openStream just bound listeners
|
|
if (!_activeSubs.size) return;
|
|
const port = getSharedPort();
|
|
if (!port) return; // SharedWorker really gone; fallback already in place
|
|
for (const [url, sub] of _activeSubs) {
|
|
sub.target.readyState = 0; // CONNECTING — the worker will fire 'open'
|
|
port.addEventListener('message', sub.route);
|
|
try { port.postMessage({ kind: 'subscribe', url }); }
|
|
catch { /* port dead immediately — skip */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
export function openStream(url) {
|
|
const port = getSharedPort();
|
|
if (!port) return new EventSource(url);
|
|
bindLifecycleOnce();
|
|
|
|
// Build an EventSource-shaped facade so consumer code is unchanged.
|
|
// `target.onmessage` / `onopen` / `onerror` are assigned by the
|
|
// consumer; the routing function below forwards events received
|
|
// from the worker (filtered by url, since one port can multiplex
|
|
// multiple subscriptions).
|
|
const target = {
|
|
onmessage: null,
|
|
onopen: null,
|
|
onerror: null,
|
|
readyState: 0, // CONNECTING
|
|
close() {
|
|
const p = _sharedPort;
|
|
if (p) {
|
|
try { p.postMessage({ kind: 'unsubscribe', url }); }
|
|
catch { /* port dead */ }
|
|
try { p.removeEventListener('message', route); }
|
|
catch { /* same */ }
|
|
}
|
|
_activeSubs.delete(url);
|
|
},
|
|
};
|
|
const route = (e) => {
|
|
// Any message from the worker is proof of life — note it
|
|
// before the URL filter, since heartbeat pings carry no URL.
|
|
noteWorkerActivity();
|
|
const m = e.data;
|
|
if (!m || m.kind === 'ping') return;
|
|
if (m.url !== url) return;
|
|
if (m.kind === 'open') {
|
|
target.readyState = 1; // OPEN
|
|
if (target.onopen) {
|
|
try { target.onopen({ target }); }
|
|
catch (err) { console.error('openStream onopen threw', err); }
|
|
}
|
|
} else if (m.kind === 'message') {
|
|
if (target.onmessage) {
|
|
try { target.onmessage({ data: m.data, target }); }
|
|
catch (err) { console.error('openStream onmessage threw', err); }
|
|
}
|
|
} else if (m.kind === 'error') {
|
|
if (target.onerror) {
|
|
try { target.onerror({ target }); }
|
|
catch (err) { console.error('openStream onerror threw', err); }
|
|
}
|
|
}
|
|
};
|
|
_activeSubs.set(url, { target, route });
|
|
port.addEventListener('message', route);
|
|
port.postMessage({ kind: 'subscribe', url });
|
|
// Seed the activity clock so the watchdog has a baseline (would
|
|
// otherwise compare against 0 and trigger immediately).
|
|
noteWorkerActivity();
|
|
return target;
|
|
}
|
|
|
|
// ─── side panel ─────────────────────────────────────────────────────────
|
|
// Singleton drawer that swipes in from the right. Long content
|
|
// (file previews, approval diffs, journald logs, applied config)
|
|
// opens here via `Panel.open(title, node)` instead of expanding
|
|
// inline. Body is swapped on each open; closing just slides out so
|
|
// the content stays visible through the transition.
|
|
export const Panel = (() => {
|
|
let root = null;
|
|
let titleEl = null;
|
|
let bodyEl = null;
|
|
let drawer = null;
|
|
/** Owner key set by `openNamed` (e.g. 'inbox'). `refresh(name, …)`
|
|
* is a no-op when the current owner doesn't match, so live
|
|
* updates can re-render an open view without grabbing focus
|
|
* from a closed one (or from an unrelated open view like a
|
|
* diff drill-in). Untyped calls via `open(title, content)`
|
|
* clear the owner — the legacy file-preview/diff/log paths
|
|
* don't participate in named-refresh semantics. */
|
|
let owner = null;
|
|
function ensure() {
|
|
if (!root) {
|
|
root = $('side-panel');
|
|
titleEl = $('side-panel-title');
|
|
bodyEl = $('side-panel-body');
|
|
drawer = root && root.querySelector('.side-panel-drawer');
|
|
}
|
|
return root != null;
|
|
}
|
|
function open(title, content) {
|
|
if (!ensure()) return;
|
|
owner = null;
|
|
titleEl.textContent = title;
|
|
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
root.classList.add('open');
|
|
root.setAttribute('aria-hidden', 'false');
|
|
}
|
|
function openNamed(name, title, content) {
|
|
open(title, content);
|
|
owner = name;
|
|
}
|
|
function refresh(name, title, content) {
|
|
if (!ensure()) return;
|
|
if (owner !== name) return;
|
|
titleEl.textContent = title;
|
|
bodyEl.replaceChildren(...(content ? [content] : []));
|
|
}
|
|
function close() {
|
|
if (!ensure()) return;
|
|
owner = null;
|
|
root.classList.remove('open');
|
|
root.setAttribute('aria-hidden', 'true');
|
|
}
|
|
// Drag-to-resize the drawer's width. See docs/web-ui.md::Side panel
|
|
// for the hit-strip + pointer-capture + localStorage persistence
|
|
// model; CSS clamps the stored value to min 320px / max 96vw and
|
|
// out-of-range stored values are dropped silently.
|
|
const WIDTH_KEY = 'hyperhive:side-panel-width';
|
|
const WIDTH_MIN = 320;
|
|
function clampWidth(w) {
|
|
// Use document.documentElement.clientWidth rather than window.innerWidth:
|
|
// clientWidth gives the actual CSS layout viewport width and is not
|
|
// altered by Firefox's Fingerprinting Protection (which rounds
|
|
// window.innerWidth/outerWidth to the nearest 200px).
|
|
const max = Math.floor(document.documentElement.clientWidth * 0.96);
|
|
return Math.max(WIDTH_MIN, Math.min(max, w));
|
|
}
|
|
function applyStoredWidth() {
|
|
if (!drawer) return;
|
|
const raw = (() => {
|
|
try { return localStorage.getItem(WIDTH_KEY); }
|
|
catch { return null; }
|
|
})();
|
|
if (!raw) return;
|
|
const parsed = parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
drawer.style.setProperty('--side-panel-w', clampWidth(parsed) + 'px');
|
|
}
|
|
function bindResize() {
|
|
if (!drawer) return;
|
|
const handle = document.createElement('div');
|
|
handle.className = 'side-panel-resize';
|
|
handle.setAttribute('role', 'separator');
|
|
handle.setAttribute('aria-orientation', 'vertical');
|
|
handle.setAttribute('aria-label', 'drag to resize side panel');
|
|
handle.title = 'drag to resize';
|
|
drawer.prepend(handle);
|
|
let dragging = false;
|
|
handle.addEventListener('pointerdown', (e) => {
|
|
e.preventDefault();
|
|
dragging = true;
|
|
document.body.classList.add('side-panel-resizing');
|
|
// Capture so we keep getting pointermove even when the cursor
|
|
// outpaces the handle band (drag-fast-then-pause loses the
|
|
// handle's :hover state otherwise).
|
|
try { handle.setPointerCapture(e.pointerId); } catch { /* legacy */ }
|
|
});
|
|
document.addEventListener('pointermove', (e) => {
|
|
if (!dragging) return;
|
|
// Drawer is anchored to the right edge — width = viewport - pointer X.
|
|
const w = clampWidth(document.documentElement.clientWidth - e.clientX);
|
|
drawer.style.setProperty('--side-panel-w', w + 'px');
|
|
});
|
|
function stopDrag() {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
document.body.classList.remove('side-panel-resizing');
|
|
// Persist the final width. Read the actual rendered width
|
|
// rather than re-deriving so the stored value matches what
|
|
// the operator saw at mouseup.
|
|
const w = drawer.getBoundingClientRect().width;
|
|
try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); }
|
|
catch { /* localStorage unavailable — width is session-only */ }
|
|
}
|
|
document.addEventListener('pointerup', stopDrag);
|
|
document.addEventListener('pointercancel', stopDrag);
|
|
// Re-clamp on viewport resize so a persisted width that exceeds
|
|
// 96vw doesn't push the drawer off-screen after a window shrink.
|
|
window.addEventListener('resize', () => {
|
|
if (dragging) return;
|
|
const cur = drawer.getBoundingClientRect().width;
|
|
const clamped = clampWidth(cur);
|
|
if (clamped !== Math.round(cur)) {
|
|
drawer.style.setProperty('--side-panel-w', clamped + 'px');
|
|
}
|
|
});
|
|
}
|
|
function bind() {
|
|
if (!ensure()) return;
|
|
$('side-panel-close').addEventListener('click', close);
|
|
$('side-panel-backdrop').addEventListener('click', close);
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && root.classList.contains('open')) close();
|
|
});
|
|
applyStoredWidth();
|
|
bindResize();
|
|
}
|
|
return { open, openNamed, refresh, close, bind };
|
|
})();
|
|
|
|
// ─── path linkification ─────────────────────────────────────────────────
|
|
// Agents constantly drop pointer strings into messages + question
|
|
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
|
|
// PATH_RE patterns becomes a clickable anchor; clicking expands an
|
|
// inline <details> with the file's contents, fetched lazily from
|
|
// /api/state-file. The legacy in-container `/state/...` prefix is
|
|
// deliberately not matched — it's ambiguous from the host's
|
|
// perspective (we'd need to know which agent the message is about
|
|
// to translate it). Prefer `/agents/<name>/state/...` in agent
|
|
// outputs and the link will resolve.
|
|
async function fetchStateFile(path) {
|
|
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
|
|
const text = await resp.text();
|
|
if (!resp.ok) throw new Error(text || ('http ' + resp.status));
|
|
return text;
|
|
}
|
|
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
|
|
// `renderRendered()` produces the rendered-tab node fresh on each
|
|
// switch; `plainText` backs the raw tab; `plainLabel` names it.
|
|
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
|
|
const tabs = el('div', { class: 'diff-base-tabs' });
|
|
const host = el('div', { class: 'preview-host' });
|
|
function show(mode) {
|
|
for (const b of tabs.children) {
|
|
b.classList.toggle('active', b.dataset.mode === mode);
|
|
}
|
|
host.replaceChildren(mode === 'plain'
|
|
? el('pre', { class: 'path-preview-body' }, plainText)
|
|
: renderRendered());
|
|
}
|
|
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
|
|
const b = el('button',
|
|
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
|
|
b.addEventListener('click', () => show(mode));
|
|
tabs.append(b);
|
|
}
|
|
show('rendered');
|
|
return el('div', {}, tabs, host);
|
|
}
|
|
// Rendered <img> for an SVG, loaded via an <img> data: URI —
|
|
// <img>-loaded SVG runs in the browser's secure static mode (no
|
|
// scripts, no external fetches), so an untrusted SVG from an
|
|
// agent's state dir can't execute code in the dashboard.
|
|
function svgImage(text) {
|
|
const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
|
|
img.addEventListener('error', () => {
|
|
img.replaceWith(el('div', { class: 'meta' },
|
|
'(could not render — see the source tab)'));
|
|
});
|
|
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
|
|
return img;
|
|
}
|
|
// Marked-rendered markdown node (raw text fallback if `marked`
|
|
// failed to load).
|
|
function mdNode(text) {
|
|
const div = el('div', { class: 'md' });
|
|
if (window.marked && typeof window.marked.parse === 'function') {
|
|
window.marked.setOptions({ breaks: true, gfm: true });
|
|
div.innerHTML = window.marked.parse(text);
|
|
// marked autolinks URLs but leaves them same-tab — open externally
|
|
// so a click never navigates away from the dashboard.
|
|
div.querySelectorAll('a[href]').forEach((a) => {
|
|
a.target = '_blank';
|
|
a.rel = 'noopener noreferrer';
|
|
});
|
|
} else {
|
|
div.textContent = text;
|
|
}
|
|
return div;
|
|
}
|
|
// Raster image extensions the preview renders as an <img> pointed
|
|
// straight at /api/state-file (served binary with a real
|
|
// content-type). SVG is handled on the text path instead.
|
|
const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
|
|
// Lazy-load `path` from /api/state-file into the side panel.
|
|
// Markdown + SVG get a rendered/plain tabbed view; raster images
|
|
// render as an <img>; every other file stays raw text in a <pre>.
|
|
async function openFilePanel(path) {
|
|
if (RASTER_RE.test(path)) {
|
|
const img = el('img', { class: 'img-preview', alt: path });
|
|
img.addEventListener('error', () => {
|
|
img.replaceWith(el('pre', { class: 'path-preview-body' },
|
|
'(could not load image — it may be missing or over the preview size cap)'));
|
|
});
|
|
img.src = '/api/state-file?path=' + encodeURIComponent(path);
|
|
Panel.open('↳ ' + path, img);
|
|
return;
|
|
}
|
|
const isMd = /\.(md|markdown)$/i.test(path);
|
|
const isSvg = /\.svg$/i.test(path);
|
|
const view = el('div');
|
|
view.textContent = '(fetching…)';
|
|
Panel.open('↳ ' + path, view);
|
|
try {
|
|
const text = await fetchStateFile(path);
|
|
if (isSvg) {
|
|
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
|
|
} else if (isMd) {
|
|
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
|
|
} else {
|
|
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
|
}
|
|
} catch (e) {
|
|
view.textContent = 'error: ' + (e.message || e);
|
|
}
|
|
}
|
|
export function makePathLink(path) {
|
|
const anchor = el('a', {
|
|
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
|
|
}, path);
|
|
anchor.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
openFilePanel(path);
|
|
});
|
|
return anchor;
|
|
}
|
|
// Append a plain-text run, with bare http(s) URLs turned into clickable
|
|
// links via the shared terminal linkifier.
|
|
export function appendText(parent, s) {
|
|
if (!s) return;
|
|
parent.appendChild(termLinkify(s));
|
|
}
|
|
// Append `text` to `parent` as a mix of text nodes + path anchors.
|
|
// `refs` is the server-attached `file_refs` array (verified-file
|
|
// tokens that appear in `text`); each occurrence of a ref becomes a
|
|
// clickable anchor that opens the file in the side panel. Anything
|
|
// 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.
|
|
export function appendLinkified(parent, text, refs) {
|
|
if (text == null) return;
|
|
const str = String(text);
|
|
const tokens = (refs || []).slice();
|
|
if (!tokens.length) {
|
|
appendText(parent, str);
|
|
return;
|
|
}
|
|
// Walk the string left-to-right, at each step looking for the
|
|
// next occurrence of any token. Longest-first tie-break so a
|
|
// ref like `/agents/foo/state/x.md` wins over a (hypothetical)
|
|
// shorter token that prefixes it. O(text * refs) worst case;
|
|
// refs is bounded server-side to whatever fits in a body, so
|
|
// this stays cheap.
|
|
tokens.sort((a, b) => b.length - a.length);
|
|
let i = 0;
|
|
while (i < str.length) {
|
|
let bestStart = -1;
|
|
let bestToken = null;
|
|
for (const t of tokens) {
|
|
const idx = str.indexOf(t, i);
|
|
if (idx === -1) continue;
|
|
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
|
|
bestStart = idx;
|
|
bestToken = t;
|
|
}
|
|
}
|
|
if (bestStart === -1) {
|
|
appendText(parent, str.slice(i));
|
|
break;
|
|
}
|
|
if (bestStart > i) {
|
|
appendText(parent, str.slice(i, bestStart));
|
|
}
|
|
parent.appendChild(makePathLink(bestToken));
|
|
i = bestStart + bestToken.length;
|
|
}
|
|
}
|
|
|
|
// ─── browser notifications ──────────────────────────────────────────────
|
|
// Fires OS notifications on three operator-bound signals:
|
|
// - new approval landed in the queue
|
|
// - new operator question queued (ask, target IS NULL)
|
|
// - broker message sent `to: "operator"`
|
|
// Permission grant is per-browser; a localStorage "muted" toggle lets
|
|
// the operator silence without revoking. Secure-context only (HTTPS /
|
|
// localhost) — on other origins the API is unavailable and we hide
|
|
// the controls.
|
|
export const NOTIF = (() => {
|
|
const supported = typeof Notification !== 'undefined';
|
|
const MUTED_KEY = 'hyperhive.notify.muted';
|
|
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
|
|
const setMuted = (v) => v
|
|
? localStorage.setItem(MUTED_KEY, '1')
|
|
: localStorage.removeItem(MUTED_KEY);
|
|
function renderControls() {
|
|
const enable = $('notif-enable');
|
|
const mute = $('notif-mute');
|
|
const unmute = $('notif-unmute');
|
|
const status = $('notif-status');
|
|
if (!enable || !mute || !unmute || !status) return;
|
|
if (!supported) {
|
|
enable.hidden = mute.hidden = unmute.hidden = true;
|
|
status.hidden = false;
|
|
status.textContent = 'notifications unsupported in this browser';
|
|
return;
|
|
}
|
|
const perm = Notification.permission;
|
|
enable.hidden = perm === 'granted';
|
|
mute.hidden = perm !== 'granted' || isMuted();
|
|
unmute.hidden = perm !== 'granted' || !isMuted();
|
|
status.hidden = perm !== 'denied';
|
|
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
|
|
}
|
|
function bind() {
|
|
const enable = $('notif-enable');
|
|
const mute = $('notif-mute');
|
|
const unmute = $('notif-unmute');
|
|
if (!supported || !enable || !mute || !unmute) return;
|
|
enable.addEventListener('click', async () => {
|
|
await Notification.requestPermission();
|
|
renderControls();
|
|
});
|
|
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
|
|
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
|
|
renderControls();
|
|
}
|
|
function show(title, body, tag) {
|
|
if (!supported) {
|
|
console.debug('notify: Notification API not supported');
|
|
return;
|
|
}
|
|
if (Notification.permission !== 'granted') {
|
|
console.debug('notify: permission not granted', Notification.permission);
|
|
return;
|
|
}
|
|
if (isMuted()) {
|
|
console.debug('notify: muted');
|
|
return;
|
|
}
|
|
try {
|
|
// Per-event tag so distinct messages stack instead of
|
|
// collapsing into one slot. Caller passes a unique tag per
|
|
// notification kind/id; we don't fall back to 'hyperhive'
|
|
// because that one tag would replace itself on every fire.
|
|
const n = new Notification(title, {
|
|
body,
|
|
tag: tag || ('hyperhive:' + Date.now()),
|
|
});
|
|
n.onclick = () => { window.focus(); n.close(); };
|
|
console.debug('notify: shown', title, 'tag=', tag);
|
|
} catch (err) {
|
|
console.warn('notification show failed', err);
|
|
}
|
|
}
|
|
return { bind, show, renderControls };
|
|
})();
|