dashboard: extract shared helpers into common.js (#406 step 1)
First slice of the app.js split (#406). Pure utility / infrastructure code that both /index.html and /flow.html use lifts out of the IIFE into a sibling ES module: - DOM helpers: `$`, `el`, `esc`, `form`, `fmtAgeSecs` - Side-panel singleton (`Panel.open` / `openNamed` / `refresh` / `close` / `bind`). The `ensure()` lazy-init makes it tolerate being imported before the DOM element exists — `bind()` still needs to be called once the host page is ready. - Path linkification + file-preview side panel: `appendLinkified`, `appendText`, `makePathLink`, plus the internal `openFilePanel` + `fetchStateFile` + `mdNode` / `svgImage` / `buildTabbedPreview` it depends on. - Browser-notification module `NOTIF` (`bind`, `show`, `renderControls`). `app.js` now imports these from `./common.js` and the duplicated definitions are gone. Each removal is replaced by a one-line breadcrumb comment so a reader chasing a name from the bundled output can find where it landed. `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in app.js for now — each has caller-specific phrasing ("X running", "X ago") that doesn't generalise cleanly. Lift them when a second consumer needs the same shape. ## Next steps (separate PRs) - Step 2: split app.js into `tabs.js` (entry for /index.html — tab renderers + tab routing + refreshState) and `flow.js` (entry for /flow.html — broker terminal + inbox derived store + compose), both importing from common.js. Updates `build.mjs` for multiple entry points and switches each HTML file's `<script src>`. - Step 3 (#408 follow-up): backend-side stream split so /flow.html doesn't have to subscribe to the dashboard's mutation events at all. ## Validation - `npm run build` clean. - Build deltas: `app.js` 154.3kb (was 153.6kb) — bundle size bumped slightly due to per-module overhead; same code under the hood. Source: app.js 2603 → 2287 lines (-316); common.js 367 lines (new). - No HTML / CSS changes. Both pages still load `/static/app.js` as before. Browser smoke test isn't possible from inside iris's container. Worth eyeballing post-deploy: - Notification toggle + send still works (NOTIF.bind, NOTIF.show) - Side panel still opens for diff / file preview / logs (Panel) - Path tokens in messages still render as clickable anchors that open the file in the side panel (appendLinkified → makePathLink → openFilePanel)
This commit is contained in:
parent
38920d3af1
commit
560360d2e3
2 changed files with 384 additions and 334 deletions
|
|
@ -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 <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') {
|
||||
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 <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);
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
|
|||
367
frontend/packages/dashboard/src/common.js
Normal file
367
frontend/packages/dashboard/src/common.js
Normal file
|
|
@ -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 <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. (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 <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 };
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue