hyperhive/frontend/packages/dashboard/src/common.js
iris f4635cc256 dashboard: full-height inbox + drag-to-resize side panel (#450 #451)
two related flyout improvements — both small, single PR.

#450 — full-height inbox

dropped the .inbox max-height: 24em cap. the side-panel-body
already provides overflow: auto, so the cap was just clamping the
inbox list short of the available panel height on tall viewports.
the inbox now fills as much of the panel as it needs and the
panel itself scrolls.

#451 — drag-to-resize side panel

added a 6px hit-strip on the drawer's left edge. mousedown/move/
up handlers track the drag and update --side-panel-w on the
drawer; the CSS variable defaults to min(760px, 94vw) (preserving
pre-#451 behaviour) and is clamped to [320px, 96vw] so a bad
stored value can never wedge the drawer off-screen.

ergonomics

- handle is invisible at rest, mauve glow on hover + during drag
  so the affordance is discoverable but doesn't compete with the
  2px mauve border-left for the visual boundary.
- body.side-panel-resizing class forces ew-resize cursor + kills
  user-select page-wide so the cursor doesn't flip back to
  default the moment it leaves the 6px band during a fast drag.
- final width persists to localStorage (key
  hyperhive:side-panel-width) so it survives reload. window
  resize re-clamps so a stored width that exceeds the new 96vw
  shrinks accordingly.
- handle is a separator role with aria-orientation: vertical +
  aria-label for screen readers.

files

- frontend/packages/dashboard/src/dashboard.css
  - .inbox: dropped max-height (#450).
  - .side-panel-drawer: width = var(--side-panel-w, min(760px,
    94vw)) + min/max clamp (#451).
  - new .side-panel-resize + body.side-panel-resizing rules.
- frontend/packages/dashboard/src/common.js
  - Panel.bind now also calls applyStoredWidth + bindResize.
  - resize handle is prepended to the drawer at bind time so
    every flyout (inbox, file preview, diff, journal) gets it.

validation

- npm run build --workspace=@hive/dashboard clean. CSS 40.9kb →
  41.3kb. app.js + flow.js grew ~0.5kb each (resize handler).
- browser smoke test isn't possible from inside iris's container;
  the resize math is straightforward (width = innerWidth -
  clientX, clamped) and the CSS variable + localStorage
  persistence are standard patterns.
2026-05-26 01:27:42 +02:00

592 lines
24 KiB
JavaScript

// 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) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[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.
// ─── 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 (app.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 <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 };
})();