diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index d4fcd6f..8fdc74f 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -1,9 +1,21 @@ // Dashboard SPA. Renders containers + approvals from `/api/state`, wires // up async-form submission (URL-encoded POST + spinner + state refresh), // and tails the unified dashboard event channel over `/dashboard/stream`. +// +// #406 step 1: pure helpers (DOM, formatters), the side-panel singleton, +// the OS-notification module, and the path-link / file-preview +// infrastructure all moved into `./common.js`. The follow-up step splits +// the broker-terminal / inbox / compose bits into a separate `flow.js` +// entry so /flow.html stops loading the tab renderers it doesn't use. import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js'; import { marked } from 'marked'; +import { + $, el, esc, form, + fmtAgeSecs, + Panel, NOTIF, + makePathLink, appendText, appendLinkified, +} from './common.js'; // Expose the previously-script-tag-provided globals so the IIFE below // keeps working unchanged. Pre-split these were attached by @@ -27,343 +39,14 @@ window.marked = marked; const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k) const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k) - // ─── helpers ──────────────────────────────────────────────────────────── - const $ = (id) => document.getElementById(id); - 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`; - const esc = (s) => String(s).replace(/[&<>"]/g, (c) => - ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) - ); - 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; - }; - 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; - }; + // Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406). - // ─── 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. - const Panel = (() => { - const root = $('side-panel'); - const titleEl = $('side-panel-title'); - const bodyEl = $('side-panel-body'); - /** 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 open(title, content) { - 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 (owner !== name) return; - titleEl.textContent = title; - bodyEl.replaceChildren(...(content ? [content] : [])); - } - function close() { - owner = null; - root.classList.remove('open'); - root.setAttribute('aria-hidden', 'true'); - } - function bind() { - $('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(); - }); - } - return { open, openNamed, refresh, close, bind }; - })(); + // Side panel singleton (Panel) moved to ./common.js (#406). - // ─── 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') { - 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; - } - 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);
-    }
-  }
-  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 `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.
-  // 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) {
-      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;
-    }
-  }
+  // Path linkification + file-preview side panel (openFilePanel,
+  // makePathLink, appendText, appendLinkified) moved to ./common.js (#406).
 
-  // ─── 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.
-  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 };
-  })();
+  // OS notification module (NOTIF) moved to ./common.js (#406).
 
   // Track which items we've already notified about so a re-render
   // doesn't re-fire for the same row. Keyed by stable ids; reset only
diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js
new file mode 100644
index 0000000..e087020
--- /dev/null
+++ b/frontend/packages/dashboard/src/common.js
@@ -0,0 +1,367 @@
+// Shared dashboard helpers — extracted from app.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.
+//
+// Both pages currently still load `app.js` as their single entry
+// point; this module is bundled inline by esbuild via the import
+// below. The follow-up step splits app.js into per-page entry
+// points (tabs.js + flow.js), at which point both will import from
+// here directly.
+
+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 app.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.
+
+// ─── 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;
+  /** 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');
+    }
+    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');
+  }
+  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();
+    });
+  }
+  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 };
+})();