frontend: extract themed dialogs + async-form handler to shared, wire agent UI

The dashboard has a themed modal/dialog system (modal.js: themedToast/
themedConfirm/themedPrompt) and a data-async form submit interceptor
(bindAsyncForms) that every dashboard action routes through. The
per-agent UI never adopted either — it had its own more primitive
data-async handler using native window.confirm()/alert() (8 call
sites) and a duplicated el() DOM helper.

- Moved el() out of dashboard/common.js into shared/src/dom.js.
- Moved modal.js + modal.css from dashboard/src/ to shared/src/,
  updating its internal el import.
- Moved bindAsyncForms from dashboard/common.js into shared/forms.js,
  alongside the asyncBtn primitive it's built on.
- Updated every dashboard file's imports to the new shared locations
  (no re-export shims).
- agent.css now @imports shared/modal.css so the dialogs render
  themed there too.
- agent/app.js: dropped its local el()/data-async duplicate, wired
  bindAsyncForms(), and replaced all 8 window.confirm() sites with
  themedConfirm (async, wrapped in a fire-and-forget IIFE where the
  call site needs a synchronous boolean return, e.g. the slash-command
  dispatcher).

Closes hyperhive#2791. Verified with a full frontend build
(npm run build) — both dashboard and agent bundles compile clean and
agent.css picks up the .tc-* dialog styles it previously lacked.
This commit is contained in:
iris 2026-07-27 18:42:19 +02:00 committed by mara
commit 7110a25cf6
19 changed files with 206 additions and 193 deletions

View file

@ -10,7 +10,9 @@
// renderers here are direct copies from core.js / logs.js with only the
// deep-link URL and count-pill id adjusted.
import { $, el, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings, bindAsyncForms } from './common.js';
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { fmtAgo, fmtElapsed, fmtDuration, truncate } from './util.js';
import { createTabStrip } from '@hive/shared/tabs.js';

View file

@ -14,8 +14,9 @@
// live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`.
import { $, el, form, Panel, appendLinkified } from './common.js';
import { themedToast } from './modal.js';
import { $, form, Panel, appendLinkified } from './common.js';
import { el } from '@hive/shared/dom.js';
import { themedToast } from '@hive/shared/modal.js';
import { epochSec, fmtAgo, fmtDuration } from './util.js';
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';

View file

@ -4,7 +4,7 @@
@import "@hive/shared/terminal.css";
@import "@hive/shared/tabs.css";
@import "@hive/shared/chrome.css";
@import "./modal.css";
@import "@hive/shared/modal.css";
/* global typography
Element-level rules shared across all three pages (index, flow,

View file

@ -4,12 +4,8 @@
// infrastructure for the side panel.
import { linkify as termLinkify } from '@hive/shared/terminal.js';
import { asyncBtn } from '@hive/shared/forms.js';
import { el } from '@hive/shared/dom.js';
import DOMPurify from 'dompurify';
// Themed dialog/toast helpers (modal.js imports `el` back from here — a safe
// deferred cycle: neither side uses the other at module-init time, only inside
// runtime handlers).
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
// ─── helpers ────────────────────────────────────────────────────────────
export const $ = (id) => document.getElementById(id);
@ -21,21 +17,6 @@ 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': '',
@ -52,70 +33,9 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
return f;
};
// Page-level submit interceptor for `data-async` forms — the pattern every
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
// Without this a `data-async` form POSTs natively and the browser navigates
// to the bare `ok` response page. Each page that renders such forms must
// call this once at boot (dashboard via ./tabs.js, /core.html via ./core.js).
// `onSuccess` runs after a successful submit unless the form opts out with
// `data-no-refresh` (forms whose mutation arrives faster via an SSE event).
export function bindAsyncForms(onSuccess) {
document.addEventListener('submit', async (e) => {
const f = e.target;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
e.preventDefault();
if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return;
if (f.dataset.prompt) {
const ans = await themedPrompt({ message: f.dataset.prompt });
if (ans === null) return; // operator hit Cancel
// Drop into a hidden input named after `data-prompt-field` (or
// 'note' by default) so the value rides along on the POST.
const field = f.dataset.promptField || 'note';
let input = f.querySelector(`input[name="${field}"]`);
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = field;
f.append(input);
}
input.value = ans;
}
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
// Inner action: POST, clear inputs, call onSuccess.
// Errors are surfaced via themedToast; the caller does not re-throw
// so asyncBtn's finally always runs (restoring the button).
const doSubmit = async () => {
let resp;
try {
resp = await fetch(f.action, {
method: f.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(f)),
redirect: 'manual',
});
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
return;
}
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
return;
}
// Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
if (!f.hasAttribute('data-no-refresh') && typeof onSuccess === 'function') {
onSuccess();
}
};
// asyncBtn guards double-submit and shows a spinner while in-flight.
// When there is no submit button (unusual), fall through without a guard.
if (btn) asyncBtn(btn, doSubmit);
else await doSubmit();
});
}
// `bindAsyncForms` (the `data-async` form submit interceptor) now lives in
// `@hive/shared/forms.js` alongside `asyncBtn` — both the dashboard and the
// per-agent UI import it directly from there rather than through this file.
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
// render helper live in the dashboard-internal `./util.js`, not here —

View file

@ -10,8 +10,9 @@
// (the same broker event channel the dashboard uses), maintaining its
// own copy of the tombstones state.
import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
import { asyncBtn } from '@hive/shared/forms.js';
import { $, form, openStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
import { createTabStrip } from '@hive/shared/tabs.js';
// ─── derived state (own copies; this bundle has its own runtime) ──────────

View file

@ -19,9 +19,10 @@
// themselves and pastes it in, same trust model as GITHUB.
// Per-tab detail comments live next to their section below.
import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import { themedConfirm, themedToast } from './modal.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
let agents = [];
// agent name → container running (bool), from /api/state. Cross-referenced by

View file

@ -13,11 +13,12 @@
import { create as termCreate } from '@hive/shared/terminal.js';
import {
$, el,
$,
NOTIF,
appendLinkified,
openStream, initServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { epochSec } from './util.js';
(() => {

View file

@ -15,8 +15,9 @@
// INFRA, and SYSTEM tabs so the operator knows how stale the output is.
import {
$, el, fmtAgeSecs, openStream, initServerWarnings,
$, fmtAgeSecs, openStream, initServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { epochSec } from './util.js';
import { createTabStrip } from '@hive/shared/tabs.js';

View file

@ -1,111 +0,0 @@
/* modal.css themed dialog component styles.
Pairs with modal.js (themedConfirm / themedPrompt / themedToast). The
dialogs are raised from common.js's data-async / data-confirm handler,
which every page loads, so common.css @imports this to keep the styles
present wherever a dialog can fire not just the main dashboard.
(Previously these rules lived in dashboard.css, so dialogs rendered
unstyled on standalone pages such as /core.) */
.tc-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1em;
background: color-mix(in srgb, var(--crust) 60%, transparent);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
.tc-box {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
box-shadow: 0 8px 40px -8px var(--crust);
padding: 1.2em 1.4em;
max-width: min(32em, 92vw);
display: flex;
flex-direction: column;
gap: 0.9em;
}
.tc-title {
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--subtext0);
}
.tc-message { color: var(--fg); line-height: 1.45; }
.tc-checks { display: flex; flex-direction: column; gap: 0.4em; }
.tc-checkrow {
display: flex;
align-items: flex-start;
gap: 0.5em;
cursor: pointer;
color: var(--subtext0);
font-size: 0.92em;
line-height: 1.35;
}
.tc-checkrow .tc-check { margin-top: 0.2em; flex: 0 0 auto; }
.tc-actions {
display: flex;
justify-content: flex-end;
gap: 0.6em;
margin-top: 0.2em;
}
.tc-cancel { color: var(--subtext0); }
.tc-confirm { color: var(--green); }
.tc-confirm.tc-danger { color: var(--red); }
/* themedPrompt input field */
.tc-promptfield { display: flex; flex-direction: column; gap: 0.4em; }
.tc-promptlabel { color: var(--subtext0); font-size: 0.9em; }
.tc-input {
font-family: inherit;
font-size: 1em;
width: 100%;
box-sizing: border-box;
background: var(--crust);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
}
.tc-input:focus { outline: 1px solid var(--green); }
/* themedPrompt's field is always a resizable textarea (Enter submits,
Shift+Enter inserts a newline) sensible min-height, wrapped output. */
.tc-textarea {
resize: vertical;
min-height: 4.5em;
line-height: 1.4;
white-space: pre-wrap;
}
/* themedToast — non-blocking transient notifications (errors/info/ok) */
.tc-toasts {
position: fixed;
top: 1em;
right: 1em;
z-index: 1100;
display: flex;
flex-direction: column;
gap: 0.5em;
max-width: min(28em, 92vw);
pointer-events: none;
}
.tc-toast {
pointer-events: auto;
cursor: pointer;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-left-width: 3px;
box-shadow: 0 4px 20px -6px var(--crust);
padding: 0.6em 0.9em;
font-size: 0.9em;
color: var(--fg);
white-space: pre-wrap;
opacity: 1;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.tc-toast-out { opacity: 0; transform: translateX(0.5em); }
.tc-toast-error { border-left-color: var(--red); }
.tc-toast-info { border-left-color: var(--purple-dim); }
.tc-toast-ok { border-left-color: var(--green); }

View file

@ -1,194 +0,0 @@
// modal.js — reusable themed modal/dialog component for the operator
// dashboard. An in-theme replacement for the browser's native
// `confirm()` / `alert()` overlays so destructive actions and prompts match
// the dashboard chrome instead of a jarring OS dialog.
//
// `openDialog` is the general primitive (any title/message/content + a row of
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
// checkboxes built on top of it. Styling lives in `dashboard.css` under the
// `.tc-*` classes.
import { el } from './common.js';
// openDialog({ title, message, content, buttons, danger, dismissable })
// → Promise resolving to the clicked button's `value`, or `null` when the
// dialog is dismissed (Escape, backdrop click, or a button whose value is
// null). `content` is an optional DOM node rendered between the message
// and the buttons (checkboxes, custom fields, …). `buttons` is
// `[{ label, value, danger?, class?, autofocus? }]`, rendered
// right-aligned. Initial focus: the `autofocus` button if any, else — for
// a `danger` dialog — the first non-destructive button (so a stray Enter
// can't fire the destructive path), else the last button.
export function openDialog(opts = {}) {
const {
title = '', message = '', content = null,
buttons = [{ label: 'ok', value: true }],
danger = false, dismissable = true,
} = opts;
return new Promise((resolve) => {
let settled = false;
function done(value) {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKey, true);
backdrop.remove();
resolve(value);
}
function onKey(e) {
if (dismissable && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
done(null);
}
}
const btnEls = buttons.map((b) => {
const btn = el('button', {
type: 'button',
class: 'btn tc-btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' tc-danger' : ''),
}, b.label);
btn.addEventListener('click', () => done(b.value));
return { spec: b, btn };
});
// Give the dialog an accessible name: label it by its title if present,
// else by its message, via `aria-labelledby` (a11y — role=dialog needs a
// name). Only the labelling element carries the id.
const labelId = 'tc-dlg-' + Math.random().toString(36).slice(2, 9);
const titleEl = title ? el('div', { class: 'tc-title', id: labelId }, title) : null;
const messageEl = message
? el('div', title ? { class: 'tc-message' } : { class: 'tc-message', id: labelId }, message)
: null;
const boxAttrs = { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' };
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
const box = el('div', boxAttrs,
titleEl,
messageEl,
content || null,
el('div', { class: 'tc-actions' }, ...btnEls.map((b) => b.btn)));
const backdrop = el('div', { class: 'tc-backdrop' }, box);
backdrop.addEventListener('click', (e) => {
if (dismissable && e.target === backdrop) done(null);
});
document.addEventListener('keydown', onKey, true);
document.body.append(backdrop);
const focusTarget = btnEls.find((b) => b.spec.autofocus)
|| (danger ? btnEls.find((b) => !b.spec.danger) : null)
|| btnEls[btnEls.length - 1];
if (focusTarget) focusTarget.btn.focus();
});
}
// themedConfirm({ title, message, danger, confirmLabel, cancelLabel, checkboxes })
// → Promise<null | { [name]: bool }>. `null` = cancelled; otherwise an
// object of the checkbox states keyed by `name` (`{}` when there are none).
// Example:
// const r = await themedConfirm({ message: `stop ${n}?`, danger: true,
// confirmLabel: '■ stop', checkboxes: [{ name: 'graceful', label: '…' }] });
// if (!r) return; // cancelled
// doStop(r.graceful);
export function themedConfirm(opts = {}) {
const {
title = '', message = '', danger = false,
confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [],
} = opts;
const boxes = checkboxes.map((cb) => {
const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name });
if (cb.checked) input.checked = true;
const row = el('label', { class: 'tc-checkrow' },
input, el('span', { class: 'tc-check-label' }, cb.label || cb.name));
return { input, row };
});
const content = boxes.length
? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row))
: null;
return openDialog({
title,
message,
content,
danger,
buttons: [
{ label: cancelLabel, value: null, class: 'tc-cancel', autofocus: danger },
{ label: confirmLabel, value: 'confirm', danger, class: 'tc-confirm', autofocus: !danger },
],
}).then((v) => {
if (v !== 'confirm') return null;
const out = {};
for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked;
return out;
});
}
// themedPrompt({ title, message, label, placeholder, value, confirmLabel, cancelLabel })
// → Promise<string | null>. Themed replacement for window.prompt(): a
// resizable <textarea> dialog that resolves to the entered string on confirm,
// or null on cancel. Chat-box key behaviour: **Enter submits**, **Shift+Enter
// inserts a newline** — so short answers are one keystroke while multi-line
// reasons (e.g. an approval deny note) are still possible. Escape cancels
// (via openDialog).
export function themedPrompt(opts = {}) {
const {
title = '', message = '', label = '', placeholder = '', value = '',
confirmLabel = 'ok', cancelLabel = 'cancel',
} = opts;
const input = el('textarea', { class: 'tc-input tc-textarea', rows: '3', placeholder });
if (value) input.value = value;
// Enter submits (clicks the confirm button mounted by openDialog);
// Shift+Enter falls through to the textarea's default newline insert.
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
input.closest('.tc-box')?.querySelector('.tc-confirm')?.click();
}
});
const content = el('div', { class: 'tc-promptfield' },
label ? el('label', { class: 'tc-promptlabel' }, label) : null,
input);
const result = openDialog({
title,
message,
content,
buttons: [
{ label: cancelLabel, value: '__cancel__', class: 'tc-cancel' },
{ label: confirmLabel, value: '__ok__', class: 'tc-confirm', autofocus: true },
],
}).then((v) => (v === '__ok__' ? input.value : null));
// Prefer focusing the field over the OK button once the dialog has mounted.
setTimeout(() => input.focus(), 0);
return result;
}
// themedToast(message, { type, duration }) — non-blocking transient
// notification; a lighter alternative to a modal for feedback that needs no
// decision (errors, validation, status). Stacks in a fixed top-right
// container, auto-dismisses after `duration` ms (errors linger longer), and
// can be clicked to dismiss early. `type` ∈ {'info','error','ok'}.
export function themedToast(message, opts = {}) {
const { type = 'info', duration } = opts;
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
let container = document.getElementById('tc-toasts');
if (!container) {
container = el('div', { id: 'tc-toasts', class: 'tc-toasts' });
document.body.append(container);
}
const toast = el('div', {
class: 'tc-toast tc-toast-' + type,
role: type === 'error' ? 'alert' : 'status',
}, message);
let removed = false;
const remove = () => {
if (removed) return;
removed = true;
toast.classList.add('tc-toast-out');
setTimeout(() => toast.remove(), 200);
};
toast.addEventListener('click', remove);
container.append(toast);
if (ms > 0) setTimeout(remove, ms);
return toast;
}

View file

@ -21,7 +21,8 @@
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200.
import { $, el } from './common.js';
import { $ } from './common.js';
import { el } from '@hive/shared/dom.js';
import { containersState } from './state.js';
import { asyncBtn } from '@hive/shared/forms.js';

View file

@ -14,8 +14,9 @@
// migration) and are no longer listed here — they surface via
// get_loose_ends / the todos pill on the agent page instead.
import { $, el } from './common.js';
import { themedConfirm, themedToast } from './modal.js';
import { $ } from './common.js';
import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from './util.js';
import { containersState } from './state.js';
import { asyncBtn } from '@hive/shared/forms.js';

View file

@ -4,9 +4,10 @@
// transient ops. See docs/web-ui.md::Container row for the rendering contract.
import {
$, el, form, fmtAgeSecs,
$, form, fmtAgeSecs,
} from './common.js';
import { themedConfirm, themedToast } from './modal.js';
import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import {
containersState, questionsState,
} from './state.js';

View file

@ -13,10 +13,12 @@
import { marked } from 'marked';
import {
$, el,
$,
Panel, NOTIF,
openStream, renderServerWarnings, bindAsyncForms,
openStream, renderServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import {
containersState, syncContainersFromSnapshot,