// 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(); }); }