// 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 // disabled (prevents a second identical request from firing). // 2. Saves `btn.innerHTML` and replaces it with a spinner during the // async operation. // 3. Re-enables the button and restores the original content when `fn` // resolves or rejects (via `finally`), so callers don't need // save/restore boilerplate. // // Usage: // btn.addEventListener('click', () => asyncBtn(btn, async () => { // const resp = await fetch('/api/...'); // if (!resp.ok) throw new Error(await resp.text()); // // handle success // })); // // Error handling: `asyncBtn` restores the button on any thrown error / // rejected promise but does NOT surface the error — callers must catch // and display it themselves (via `themedToast`, `alert`, a status span, // etc.) inside `fn` without re-throwing. `fn` must not let errors escape // unhandled: `asyncBtn` returns the `fn().finally(...)` promise so // callers can optionally chain `.catch` or `await`, but does not add its // own catch — an unhandled rejection from `fn` will propagate normally. export function asyncBtn(btn, fn) { if (btn.disabled) return; // double-click guard const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; return fn().finally(() => { btn.disabled = false; 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(); }); }