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

@ -1,5 +1,9 @@
// Shared async-button primitive. Used by both the dashboard and the
// per-agent UI for any button that triggers a network action.
// Shared async-form primitives. Used by both the dashboard and the
// per-agent UI for any button/form that triggers a network action.
// Two pieces: `asyncBtn` (below) is the low-level per-button primitive;
// `bindAsyncForms` (further down) is the page-level `data-async` form
// submit interceptor built on top of it + the themed dialogs in
// `modal.js`.
//
// `asyncBtn(btn, fn)` — the single reusable component:
// 1. Guards double-click: returns immediately if `btn` is already
@ -34,3 +38,75 @@ export function asyncBtn(btn, fn) {
btn.innerHTML = orig;
});
}
// Page-level submit interceptor for `data-async` forms — the pattern every
// dashboard/agent action button uses (meta-update, spawn, cancel, purge,
// rebuild, …). 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. `onSuccess` runs after a
// successful submit unless the form opts out with `data-no-refresh` (forms
// whose mutation arrives faster via an SSE event).
//
// Confirmation (`data-confirm`) and free-text prompts (`data-prompt` /
// `data-prompt-field`) are surfaced via the themed dialogs in `modal.js`
// rather than native `confirm()`/`prompt()`, and errors via `themedToast`
// rather than `alert()`, so every page gets the same in-theme experience.
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
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();
});
}