diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index d71f812e..88ea4588 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -15,6 +15,7 @@ // registers once via `initCall`. import { $, el, form, Panel, appendLinkified } from './common.js'; +import { themedToast } from './modal.js'; import { fmtAgo } from './util.js'; import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js'; @@ -575,7 +576,7 @@ function buildQuestionLi(q) { const existing = f.querySelector('input[name="answer"]'); if (existing) existing.remove(); f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); - if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } + if (!merged) { ev.preventDefault(); themedToast('pick an option or type an answer', { type: 'error' }); } }, true); if (hasOptions) f.append(optionGroup); const buttons = el('div', { class: 'q-buttons' }); diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index ba351a83..370517e5 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -4,6 +4,10 @@ // infrastructure for the side panel. import { linkify as termLinkify } from '@hive/shared/terminal.js'; +// 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); @@ -58,9 +62,9 @@ export function bindAsyncForms(onSuccess) { const f = e.target; if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; e.preventDefault(); - if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; + if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return; if (f.dataset.prompt) { - const ans = prompt(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. @@ -88,7 +92,7 @@ export function bindAsyncForms(onSuccess) { || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); - alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); + themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); if (btn) { btn.disabled = false; btn.innerHTML = original; } return; } @@ -101,7 +105,7 @@ export function bindAsyncForms(onSuccess) { onSuccess(); } } catch (err) { - alert('action failed: ' + err); + themedToast('action failed: ' + err, { type: 'error' }); if (btn) { btn.disabled = false; btn.innerHTML = original; } } }); diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 08f637f7..bf8fcd7f 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -467,6 +467,52 @@ body.dashboard-shell { .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); } + +/* 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); } + .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 index 01ff8f24..fe3d05f4 100644 --- a/frontend/packages/dashboard/src/modal.js +++ b/frontend/packages/dashboard/src/modal.js @@ -123,3 +123,60 @@ export function themedConfirm(opts = {}) { return out; }); } + +// themedPrompt({ title, message, label, placeholder, value, confirmLabel, cancelLabel }) +// → Promise. Themed replacement for window.prompt(): an input +// dialog that resolves to the entered string on confirm, or null on cancel. +export function themedPrompt(opts = {}) { + const { + title = '', message = '', label = '', placeholder = '', value = '', + confirmLabel = 'ok', cancelLabel = 'cancel', + } = opts; + const input = el('input', { type: 'text', class: 'tc-input', placeholder }); + if (value) input.value = value; + 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; +} diff --git a/frontend/packages/dashboard/src/schedules.js b/frontend/packages/dashboard/src/schedules.js index b396f4d8..3ae45889 100644 --- a/frontend/packages/dashboard/src/schedules.js +++ b/frontend/packages/dashboard/src/schedules.js @@ -12,6 +12,7 @@ // target-chip pickers. Render + format helpers come from util.js. import { $, el } from './common.js'; +import { themedConfirm, themedToast } from './modal.js'; import { paintAtomic, fmtAgo, fmtDuration } from './util.js'; import { containersState } from './state.js'; @@ -530,14 +531,14 @@ async function submitNewScheduleInline(tr, submitBtn) { const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || ''); if (!targets.length) { - alert('schedule must have at least one target — tick at least one agent column'); + themedToast('schedule must have at least one target — tick at least one agent column', { type: 'error' }); return; } - if (!body) { alert('prompt body must be non-empty'); return; } - if (!firstFireStr) { alert('first fire timestamp is required'); return; } + if (!body) { themedToast('prompt body must be non-empty', { type: 'error' }); return; } + if (!firstFireStr) { themedToast('first fire timestamp is required', { type: 'error' }); return; } const firstFireDate = new Date(firstFireStr); if (Number.isNaN(firstFireDate.getTime())) { - alert('first fire is not a valid datetime'); return; + themedToast('first fire is not a valid datetime', { type: 'error' }); return; } const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000); @@ -554,7 +555,7 @@ async function submitNewScheduleInline(tr, submitBtn) { }; const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1); if (Number.isNaN(intervalTotal)) { - alert('interval fields must be non-negative integers (or blank for one-shot)'); + themedToast('interval fields must be non-negative integers (or blank for one-shot)', { type: 'error' }); return; } const interval_seconds = intervalTotal > 0 ? intervalTotal : null; @@ -574,14 +575,14 @@ async function submitNewScheduleInline(tr, submitBtn) { }); if (!resp.ok) { const text = await resp.text().catch(() => ''); - alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : '')); + themedToast('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); return; } // Reset carry so the next render shows an empty row. resetNewScheduleCarry(); await refreshSchedules(); } catch (err) { - alert('schedule submit failed: ' + err); + themedToast('schedule submit failed: ' + err, { type: 'error' }); } finally { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; @@ -899,11 +900,11 @@ async function submitEditSchedule(originalSchedule, form_) { const newDescription = String(fd.get('description') || '').trim(); const newNextFireStr = String(fd.get('next_fire') || ''); - if (!newBody) { alert('body must be non-empty'); return; } - if (!newNextFireStr) { alert('next-fire timestamp is required'); return; } + if (!newBody) { themedToast('body must be non-empty', { type: 'error' }); return; } + if (!newNextFireStr) { themedToast('next-fire timestamp is required', { type: 'error' }); return; } const newNextFireDate = new Date(newNextFireStr); if (Number.isNaN(newNextFireDate.getTime())) { - alert('next-fire is not a valid datetime'); return; + themedToast('next-fire is not a valid datetime', { type: 'error' }); return; } const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000); @@ -911,7 +912,7 @@ async function submitEditSchedule(originalSchedule, form_) { // `edit_interval_*` names in this form). const intervalTotal = intervalSecondsFromFormData(fd, 'edit_interval_'); if (Number.isNaN(intervalTotal)) { - alert('interval fields must be non-negative integers'); + themedToast('interval fields must be non-negative integers', { type: 'error' }); return; } const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null; @@ -929,7 +930,7 @@ async function submitEditSchedule(originalSchedule, form_) { .map((t) => t.target), ); if (!newTargets.size) { - alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead'); + themedToast('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead', { type: 'error' }); return; } const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t)); @@ -971,14 +972,14 @@ async function submitEditSchedule(originalSchedule, form_) { }); if (!resp.ok) { const text = await resp.text().catch(() => ''); - alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : '')); + themedToast('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); return; } editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); await refreshSchedules(); } catch (err) { - alert('edit failed: ' + err); + themedToast('edit failed: ' + err, { type: 'error' }); } finally { if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; } } @@ -992,7 +993,7 @@ async function fireScheduleNow(id, isOneShot, targets, btn) { : `fire schedule #${id} now to ${targetList}?\n\n` + 'this is RECURRING — sends an extra pulse out-of-band. ' + 'the regular cadence keeps firing on schedule.'; - if (!confirm(prompt)) return; + if (!(await themedConfirm({ message: prompt, danger: true }))) return; // Capture child nodes so we can restore on error, then replace // with DOM-built content (textContent + element children rather // than innerHTML — the format string only carries server-side @@ -1017,7 +1018,7 @@ async function fireScheduleNow(id, isOneShot, targets, btn) { }); if (!resp.ok) { const text = await resp.text().catch(() => ''); - alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : '')); + themedToast('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); restoreBtn(); return; } @@ -1041,16 +1042,16 @@ async function fireScheduleNow(id, isOneShot, targets, btn) { // refresh wipes the row in place. setTimeout(refreshSchedules, 1500); } catch (err) { - alert('fire-now failed: ' + err); + themedToast('fire-now failed: ' + err, { type: 'error' }); restoreBtn(); } } async function cancelScheduleAll(id) { - if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return; + if (!(await themedConfirm({ message: `cancel schedule #${id}? this stops all future fires for every target.`, danger: true }))) return; await postScheduleCancel(id, null); } async function cancelScheduleTargets(id, targets) { - if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return; + if (!(await themedConfirm({ message: `cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`, danger: true }))) return; await postScheduleCancel(id, targets); } async function postScheduleCancel(id, targets) { @@ -1063,12 +1064,12 @@ async function postScheduleCancel(id, targets) { const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts); if (!resp.ok) { const text = await resp.text().catch(() => ''); - alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : '')); + themedToast('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); return; } await refreshSchedules(); } catch (err) { - alert('cancel failed: ' + err); + themedToast('cancel failed: ' + err, { type: 'error' }); } } diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 4765815e..08f40813 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -18,7 +18,7 @@ import { makePathLink, appendText, appendLinkified, openStream, renderServerWarnings, bindAsyncForms, } from './common.js'; -import { themedConfirm } from './modal.js'; +import { themedConfirm, themedToast } from './modal.js'; import { createTabStrip } from '@hive/shared/tabs.js'; import { containersState, syncContainersFromSnapshot, @@ -295,10 +295,10 @@ window.marked = marked; || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); - alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); + themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' }); } } catch (err) { - alert('action failed: ' + err); + themedToast('action failed: ' + err, { type: 'error' }); } } @@ -1056,7 +1056,7 @@ window.marked = marked; const promptMsg = names.length === 1 ? `move ${names[0]} → ${newParentLabel}?` : `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`; - if (!confirm(promptMsg)) { + if (!(await themedConfirm({ message: promptMsg, danger: true }))) { sel.selectedIndex = 0; return; } @@ -1105,7 +1105,7 @@ window.marked = marked; sel.disabled = false; sel.selectedIndex = 0; if (failures.length) { - alert(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n')); + themedToast(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error' }); } }); wrap.append(sel); @@ -1216,7 +1216,7 @@ window.marked = marked; btn.disabled = false; btn.innerHTML = original; if (failures.length) { - alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n')); + themedToast(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error' }); } // Container-lifecycle events (ContainerStateChanged / // ContainerRemoved / RebuildQueueChanged) flow over the existing