// Shared dashboard helpers — extracted from the original monolithic // dashboard JS as step 1 of the #406 split. These bits are used by // both the tab dashboard (index.html) and the flow page (flow.html): // pure DOM helpers, the side-panel singleton, the OS-notification // module, and the path-link / file-preview infrastructure for the // side panel. // // Each page now has its own entry point — `./tabs.js` for index.html, // `./flow.js` for flow.html — and both import from here directly // (#406 steps 2 + 3 complete; #406 closed). 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 (#448) ────────────────────────────────────── // Returns an EventSource-shaped object backed by a SharedWorker that // holds ONE upstream `new EventSource(url)` and fans events out to // every connected tab. Replaces direct `new EventSource(url)` at the // dashboard's two consumer sites (tabs.js inline + flow.js via // terminal.js's `streamFactory` option) so N hyperhive tabs share // ONE backend connection — way under the browser's per-host // connection cap, immune to per-tab throttling that drops the SSE // when Firefox suspends background tabs. // // Graceful fallback to direct EventSource on environments without // SharedWorker (some embedded browsers, some Safari versions). The // per-tab connection cost is the same as today — no regression. // // The page consumer uses the returned object like a regular // EventSource: assign `onmessage` / `onopen` / `onerror`. `.close()` // tells the worker to drop the subscription; the worker closes the // upstream EventSource 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 one may have been collected if all other // tabs closed while this page was frozen — argus nit on #453). 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; } // Registry of live subscriptions on this page. Keyed by url so a // second openStream call for the same URL (would only happen on a // hypothetical multi-consumer page) attaches to the existing route // rather than overlapping. Each entry caches 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-restore path correct if that changes // (e.g. /index.html later subscribing to two streams). const _activeSubs = new Map(); // One-shot wiring of the page-wide lifecycle hooks: on bfcache // freeze (`pagehide { persisted: true }`) we unsubscribe so the // worker can close the upstream when the last live subscriber // leaves; on bfcache restore (`pageshow { persisted: true }`) we // invalidate the cached port (it may be dead if all other tabs // closed during the freeze) and re-attach every active subscription // to a fresh port. argus nit on #453: without this, the consumer's // onmessage stays bound but no events flow after a bfcache restore. let _lifecycleBound = false; function bindLifecycleOnce() { if (_lifecycleBound) return; _lifecycleBound = true; 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) => { const m = e.data; if (!m || 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 }); 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'); } // #451: drag-to-resize the drawer's width. Listens on a thin // hit-strip glued to the drawer's left edge; mousedown captures // pointermove + pointerup on the document so the drag continues // even if the cursor strays outside the 6px handle band. Width // persists to localStorage so it survives page reload. The CSS // clamps the value (min-width: 320px, max-width: 96vw) — drop // unparseable / out-of-range stored values silently. const WIDTH_KEY = 'hyperhive:side-panel-width'; const WIDTH_MIN = 320; function clampWidth(w) { const max = Math.floor(window.innerWidth * 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(window.innerWidth - 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
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//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 for an SVG, loaded via an data: URI — // -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. (issue #233) 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 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 ; every other file stays raw text in a
.
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 };
})();