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

@ -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;
}