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:
parent
a1263a9ed6
commit
7110a25cf6
19 changed files with 206 additions and 193 deletions
23
frontend/packages/shared/src/dom.js
Normal file
23
frontend/packages/shared/src/dom.js
Normal file
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
114
frontend/packages/shared/src/modal.css
Normal file
114
frontend/packages/shared/src/modal.css
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/* modal.css — themed dialog component styles.
|
||||
Pairs with modal.js (themedConfirm / themedPrompt / themedToast). The
|
||||
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 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;
|
||||
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); }
|
||||
195
frontend/packages/shared/src/modal.js
Normal file
195
frontend/packages/shared/src/modal.js
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// 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 `modal.css` under the
|
||||
// `.tc-*` classes (imported by both packages' base stylesheets).
|
||||
|
||||
import { el } from './dom.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;
|
||||
}
|
||||
Loading…
Reference in a new issue