diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 08f637f7..a837d12a 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -414,59 +414,6 @@ body.dashboard-shell { } .container-row .actions form.inline { display: inline-block; margin: 0; } -/* Themed confirm modal — `themedConfirm()` in common.js. In-theme replacement - for the native confirm() on destructive actions (stop / restart / destroy), - carrying the optional "stop gracefully" checkbox. */ -.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); } - .agent-status { font-size: 0.82em; color: var(--subtext0); diff --git a/frontend/packages/dashboard/src/modal.js b/frontend/packages/dashboard/src/modal.js deleted file mode 100644 index 5e6fd406..00000000 --- a/frontend/packages/dashboard/src/modal.js +++ /dev/null @@ -1,115 +0,0 @@ -// 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. -// -// `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. - -import { el } from './common.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 }; - }); - - const box = el('div', { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' }, - title ? el('div', { class: 'tc-title' }, title) : null, - message ? el('div', { class: 'tc-message' }, message) : null, - 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` = 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; - }); -} diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 4765815e..ee69e53a 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -18,7 +18,6 @@ import { makePathLink, appendText, appendLinkified, openStream, renderServerWarnings, bindAsyncForms, } from './common.js'; -import { themedConfirm } from './modal.js'; import { createTabStrip } from '@hive/shared/tabs.js'; import { containersState, syncContainersFromSnapshot, @@ -282,8 +281,8 @@ window.marked = marked; }, true); // Single-agent POST helper shared by all menu items. - async function agentMenuPost(actionPath, name, body, graceful) { - const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=1' : ''); + async function agentMenuPost(actionPath, name, body) { + const url = actionPath + encodeURIComponent(name); try { const resp = await fetch(url, { method: 'POST', @@ -324,20 +323,8 @@ window.marked = marked; }, label); item.addEventListener('click', async () => { closeAllAgentMenus(); - let graceful = false; - if (opts.confirm) { - const r = await themedConfirm({ - message: opts.confirm, - danger: true, - confirmLabel: opts.confirmLabel || 'confirm', - checkboxes: opts.graceful - ? [{ name: 'graceful', label: 'stop gracefully — let the agent finish its turn and flush state before the container stops' }] - : [], - }); - if (!r) return; - graceful = !!r.graceful; - } - await agentMenuPost(opts.action, c.name, opts.body || null, graceful); + if (opts.confirm && !confirm(opts.confirm)) return; + await agentMenuPost(opts.action, c.name, opts.body || null); }); li.append(item); return li; @@ -365,7 +352,7 @@ window.marked = marked; if (c.running) { dropdown.append( menuItem('↺ R3ST4RT', { action: '/restart/', confirm: `restart ${c.name}?` }), - menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }), + menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?` }), ); } else { dropdown.append( @@ -995,8 +982,6 @@ window.marked = marked; addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, { action: '/kill/', confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, - confirmLabel: '■ stop', - graceful: true, disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)), }); addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, { @@ -1160,19 +1145,7 @@ window.marked = marked; btn.addEventListener('click', async () => { if (btn.disabled) return; const msg = opts.confirm(names); - let graceful = false; - if (msg) { - const r = await themedConfirm({ - message: msg, - danger: true, - confirmLabel: opts.confirmLabel || 'confirm', - checkboxes: opts.graceful - ? [{ name: 'graceful', label: 'stop gracefully — let each agent finish its turn and flush state before the container stops' }] - : [], - }); - if (!r) return; - graceful = !!r.graceful; - } + if (msg && !confirm(msg)) return; btn.disabled = true; const original = btn.innerHTML; btn.innerHTML = ' ' + label; @@ -1195,7 +1168,7 @@ window.marked = marked; : new URLSearchParams(opts.body || {}); const url = opts.perAgentBodyFor ? opts.action - : opts.action + encodeURIComponent(name) + (graceful ? '?graceful=1' : ''); + : opts.action + encodeURIComponent(name); try { const resp = await fetch(url, { method: 'POST',