From 7110a25cf6f0f9d863fbe27b6f9a4773fe3a9bc0 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 27 Jul 2026 18:42:19 +0200 Subject: [PATCH] frontend: extract themed dialogs + async-form handler to shared, wire agent UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/packages/agent/src/agent.css | 4 + frontend/packages/agent/src/app.js | 127 +++++++----------- frontend/packages/dashboard/src/builds.js | 4 +- frontend/packages/dashboard/src/call.js | 5 +- frontend/packages/dashboard/src/common.css | 2 +- frontend/packages/dashboard/src/common.js | 88 +----------- frontend/packages/dashboard/src/core.js | 5 +- .../packages/dashboard/src/credentials.js | 5 +- frontend/packages/dashboard/src/flow.js | 3 +- frontend/packages/dashboard/src/logs.js | 3 +- .../packages/dashboard/src/permissions.js | 3 +- frontend/packages/dashboard/src/schedules.js | 5 +- frontend/packages/dashboard/src/swarm.js | 5 +- frontend/packages/dashboard/src/tabs.js | 6 +- frontend/packages/shared/package.json | 5 +- frontend/packages/shared/src/dom.js | 23 ++++ frontend/packages/shared/src/forms.js | 80 ++++++++++- .../{dashboard => shared}/src/modal.css | 11 +- .../{dashboard => shared}/src/modal.js | 15 ++- 19 files changed, 206 insertions(+), 193 deletions(-) create mode 100644 frontend/packages/shared/src/dom.js rename frontend/packages/{dashboard => shared}/src/modal.css (84%) rename frontend/packages/{dashboard => shared}/src/modal.js (94%) diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 985e2813..6854e3e8 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -6,6 +6,10 @@ agent SUB-pages (stats, screen) the same back-link nav the dashboard's standalone pages use. The live terminal page keeps its own header. */ @import "@hive/shared/chrome.css"; +/* Themed dialog/toast styles for the shared bindAsyncForms handler + any + direct themedConfirm/themedToast calls, so the per-agent UI's + confirmations and errors render in-theme instead of unstyled. */ +@import "@hive/shared/modal.css"; /* ─── full-screen layout overrides ───────────────────────────────── The agent page mounts a full-viewport terminal under a fixed diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index ed41ed00..c650910e 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -3,7 +3,9 @@ // actions (send / login/* / dashboard rebuild). import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js'; -import { asyncBtn } from '@hive/shared/forms.js'; +import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js'; +import { themedConfirm } from '@hive/shared/modal.js'; +import { el } from '@hive/shared/dom.js'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; @@ -22,19 +24,6 @@ window.marked = marked; const escText = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) ); - 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 e.setAttribute(k, v); - } - for (const c of children) { - if (c == null) continue; - e.append(c.nodeType ? c : document.createTextNode(c)); - } - return e; - }; // Base URL of the host dashboard (core backend). Set once the first // /api/state lands. Operator-authority actions (answering a question @@ -43,41 +32,9 @@ window.marked = marked; let dashboardBase = ''; // ─── async-form submit (shared with dashboard) ────────────────────────── - document.addEventListener('submit', async (e) => { - const f = e.target; - if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; - e.preventDefault(); - if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; - const btn = f.querySelector('button[type="submit"], button:not([type])'); - const original = btn ? btn.innerHTML : ''; - if (btn) { btn.disabled = true; btn.innerHTML = ''; } - try { - const 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', - }); - const ok = resp.ok || resp.type === 'opaqueredirect' - || (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ''); - alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); - if (btn) { btn.disabled = false; btn.innerHTML = original; } - return; - } - // Clear text inputs the operator typed into (the form value was sent). - f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); - // Re-enable the button — refreshState() often skips re-rendering the - // form (status unchanged), so without this the spinner sticks and - // the operator can't submit again. - if (btn) { btn.disabled = false; btn.innerHTML = original; } - refreshState(); - } catch (err) { - alert('action failed: ' + err); - if (btn) { btn.disabled = false; btn.innerHTML = original; } - } - }); + // Themed confirm/prompt/toast dialogs + the asyncBtn spinner instead of + // native confirm()/alert() dialogs that broke out of the page theme. + bindAsyncForms(() => refreshState()); // ─── side panel (singleton drawer for inbox + loose-ends flyouts) ────── // Shared shape with the dashboard's panel. Candidate for extraction @@ -214,8 +171,8 @@ window.marked = marked; el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'), 'rebuild container', ); - rebuildBtn.addEventListener('click', () => { - if (!window.confirm(`rebuild ${label}? container will hot-reload.`)) return; + rebuildBtn.addEventListener('click', async () => { + if (!(await themedConfirm({ message: `rebuild ${label}? container will hot-reload.`, confirmLabel: '↻ rebuild' }))) return; closeOverflowMenu(); const f = document.createElement('form'); f.method = 'POST'; @@ -237,8 +194,11 @@ window.marked = marked; el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'), 'new claude session', ); - newSessBtn.addEventListener('click', () => { - if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return; + newSessBtn.addEventListener('click', async () => { + if (!(await themedConfirm({ + message: 'arm a fresh claude session for the next turn? all prior --continue context will be dropped.', + danger: true, confirmLabel: '↻ arm fresh session', + }))) return; newSessBtn.disabled = true; closeOverflowMenu(); postNewSession().finally(() => { newSessBtn.disabled = false; }); @@ -263,14 +223,16 @@ window.marked = marked; el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '🔓'), 'logout', ); - logoutBtn.addEventListener('click', () => { - if (!window.confirm( - `log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` + - `credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` + - `and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` + - `login screen. prior --continue session history is preserved — the agent picks up ` + - `where it left off on the next turn after re-login.` - )) return; + logoutBtn.addEventListener('click', async () => { + if (!(await themedConfirm({ + message: + `log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` + + `credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` + + `and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` + + `login screen. prior --continue session history is preserved — the agent picks up ` + + `where it left off on the next turn after re-login.`, + danger: true, confirmLabel: '🔓 log out', + }))) return; logoutBtn.disabled = true; closeOverflowMenu(); postLogout().finally(() => { logoutBtn.disabled = false; }); @@ -641,20 +603,28 @@ window.marked = marked; postCompact(); return true; case '/new-session': - if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) { - postNewSession(); - } + // Fire the (async) themed confirm without blocking this function's + // synchronous `true` return — the caller only needs to know the + // line was a recognized slash command, not that the action fired. + (async () => { + if (await themedConfirm({ + message: 'arm a fresh claude session for the next turn? all prior --continue context will be dropped.', + danger: true, confirmLabel: '↻ arm fresh session', + })) postNewSession(); + })(); return true; case '/logout': - if (window.confirm( - `log out? this SIGINTs any running claude turn, deletes only the OAuth ` + - `credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` + - `and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` + - `login screen. prior --continue session history is preserved — the agent picks up ` + - `where it left off on the next turn after re-login.` - )) { - postLogout(); - } + (async () => { + if (await themedConfirm({ + message: + `log out? this SIGINTs any running claude turn, deletes only the OAuth ` + + `credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` + + `and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` + + `login screen. prior --continue session history is preserved — the agent picks up ` + + `where it left off on the next turn after re-login.`, + danger: true, confirmLabel: '🔓 log out', + })) postLogout(); + })(); return true; case '/model': { const parts = trimmed.split(/\s+/); @@ -1046,13 +1016,14 @@ window.marked = marked; + 'history shown here is the most-recent-N regardless of state, ' + 'so the list itself stays visible.', }, '✓ mark all read'); - btn.addEventListener('click', () => { + btn.addEventListener('click', async () => { if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; } if (!label) { status.textContent = 'agent label unknown'; return; } - if (!window.confirm( - `mark every queued message for ${label} as read? ` - + `the message history shown stays; only the unread queue is drained.` - )) return; + if (!(await themedConfirm({ + message: `mark every queued message for ${label} as read? ` + + `the message history shown stays; only the unread queue is drained.`, + confirmLabel: '✓ mark all read', + }))) return; status.textContent = 'clearing…'; asyncBtn(btn, async () => { try { diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 26d24f97..a20d1270 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -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'; diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 601682a6..9e8df3a6 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -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'; diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 8ae53ae8..ae16d272 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -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, diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 7b6dff74..44d6a56d 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -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) => ({ '&':'&', '<':'<', '>':'>', '"':'"' }[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 — diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 7723a754..ecc14621 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -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) ────────── diff --git a/frontend/packages/dashboard/src/credentials.js b/frontend/packages/dashboard/src/credentials.js index c2c9ccd7..fda15724 100644 --- a/frontend/packages/dashboard/src/credentials.js +++ b/frontend/packages/dashboard/src/credentials.js @@ -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 diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js index 94ab12f4..eab0fed3 100644 --- a/frontend/packages/dashboard/src/flow.js +++ b/frontend/packages/dashboard/src/flow.js @@ -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'; (() => { diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js index 4200ab7a..959f5c18 100644 --- a/frontend/packages/dashboard/src/logs.js +++ b/frontend/packages/dashboard/src/logs.js @@ -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'; diff --git a/frontend/packages/dashboard/src/permissions.js b/frontend/packages/dashboard/src/permissions.js index 02f6eb10..562f9e30 100644 --- a/frontend/packages/dashboard/src/permissions.js +++ b/frontend/packages/dashboard/src/permissions.js @@ -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'; diff --git a/frontend/packages/dashboard/src/schedules.js b/frontend/packages/dashboard/src/schedules.js index 778c17fb..27c18bfc 100644 --- a/frontend/packages/dashboard/src/schedules.js +++ b/frontend/packages/dashboard/src/schedules.js @@ -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'; diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index 11e72478..fa498500 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -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'; diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index f596341b..7989fc1e 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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, diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index b3a1e8ab..41ee6ede 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -15,7 +15,10 @@ "./base.css": "./src/base.css", "./terminal.css": "./src/terminal.css", "./chrome.css": "./src/chrome.css", - "./forms.js": "./src/forms.js" + "./forms.js": "./src/forms.js", + "./dom.js": "./src/dom.js", + "./modal.js": "./src/modal.js", + "./modal.css": "./src/modal.css" }, "files": [ "src/" diff --git a/frontend/packages/shared/src/dom.js b/frontend/packages/shared/src/dom.js new file mode 100644 index 00000000..da314e69 --- /dev/null +++ b/frontend/packages/shared/src/dom.js @@ -0,0 +1,23 @@ +// Tiny DOM-builder helper shared by the dashboard and the per-agent UI — +// both packages built their own copy independently; this is the merged +// canonical version (dashboard's, which had the extra `data-` branch; +// functionally identical to the `class`/`html` handling either package +// used). +// +// `el(tag, attrs, ...children)` creates an element, applying `attrs` as +// either the `class`/`html` special cases or plain attributes, and +// appending `children` (strings become text nodes, `null`/`undefined` +// entries are skipped so callers can inline conditional children). +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 e.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + e.append(c.nodeType ? c : document.createTextNode(c)); + } + return e; +}; diff --git a/frontend/packages/shared/src/forms.js b/frontend/packages/shared/src/forms.js index a68cf694..51e2e9d6 100644 --- a/frontend/packages/shared/src/forms.js +++ b/frontend/packages/shared/src/forms.js @@ -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(); + }); +} diff --git a/frontend/packages/dashboard/src/modal.css b/frontend/packages/shared/src/modal.css similarity index 84% rename from frontend/packages/dashboard/src/modal.css rename to frontend/packages/shared/src/modal.css index 31318c18..6478c9d4 100644 --- a/frontend/packages/dashboard/src/modal.css +++ b/frontend/packages/shared/src/modal.css @@ -1,10 +1,13 @@ /* 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. + dialogs are raised from the shared `bindAsyncForms` data-async / + data-confirm handler (forms.js), used by both the dashboard and the + per-agent UI, so both packages' base stylesheets (common.css / agent.css) + @import this to keep the styles present wherever a dialog can fire. (Previously these rules lived in dashboard.css, so dialogs rendered - unstyled on standalone pages such as /core.) */ + unstyled on standalone dashboard pages such as /core, and the per-agent + UI never had them at all — a themed-dialogs consistency fix moved them + here and wired both packages' base stylesheets to import this file.) */ .tc-backdrop { position: fixed; diff --git a/frontend/packages/dashboard/src/modal.js b/frontend/packages/shared/src/modal.js similarity index 94% rename from frontend/packages/dashboard/src/modal.js rename to frontend/packages/shared/src/modal.js index 1f9b511a..d24d7189 100644 --- a/frontend/packages/dashboard/src/modal.js +++ b/frontend/packages/shared/src/modal.js @@ -1,14 +1,15 @@ -// 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. +// modal.js — reusable themed modal/dialog component, shared by the +// dashboard and the per-agent UI. An in-theme replacement for the +// browser's native `confirm()` / `alert()` overlays so destructive +// actions and prompts match each page's 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. +// checkboxes built on top of it. Styling lives in `modal.css` under the +// `.tc-*` classes (imported by both packages' base stylesheets). -import { el } from './common.js'; +import { el } from './dom.js'; // openDialog({ title, message, content, buttons, danger, dismissable }) // → Promise resolving to the clicked button's `value`, or `null` when the