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
|
|
@ -6,6 +6,10 @@
|
||||||
agent SUB-pages (stats, screen) the same back-link nav the dashboard's
|
agent SUB-pages (stats, screen) the same back-link nav the dashboard's
|
||||||
standalone pages use. The live terminal page keeps its own header. */
|
standalone pages use. The live terminal page keeps its own header. */
|
||||||
@import "@hive/shared/chrome.css";
|
@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 ─────────────────────────────────
|
/* ─── full-screen layout overrides ─────────────────────────────────
|
||||||
The agent page mounts a full-viewport terminal under a fixed
|
The agent page mounts a full-viewport terminal under a fixed
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
// actions (send / login/* / dashboard rebuild).
|
// actions (send / login/* / dashboard rebuild).
|
||||||
|
|
||||||
import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js';
|
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 { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
|
@ -22,19 +24,6 @@ window.marked = marked;
|
||||||
const escText = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
const escText = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[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
|
// Base URL of the host dashboard (core backend). Set once the first
|
||||||
// /api/state lands. Operator-authority actions (answering a question
|
// /api/state lands. Operator-authority actions (answering a question
|
||||||
|
|
@ -43,41 +32,9 @@ window.marked = marked;
|
||||||
let dashboardBase = '';
|
let dashboardBase = '';
|
||||||
|
|
||||||
// ─── async-form submit (shared with dashboard) ──────────────────────────
|
// ─── async-form submit (shared with dashboard) ──────────────────────────
|
||||||
document.addEventListener('submit', async (e) => {
|
// Themed confirm/prompt/toast dialogs + the asyncBtn spinner instead of
|
||||||
const f = e.target;
|
// native confirm()/alert() dialogs that broke out of the page theme.
|
||||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
bindAsyncForms(() => refreshState());
|
||||||
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 = '<span class="spinner">◐</span>'; }
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
||||||
// Shared shape with the dashboard's panel. Candidate for extraction
|
// 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' }, '↻'),
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'),
|
||||||
'rebuild container',
|
'rebuild container',
|
||||||
);
|
);
|
||||||
rebuildBtn.addEventListener('click', () => {
|
rebuildBtn.addEventListener('click', async () => {
|
||||||
if (!window.confirm(`rebuild ${label}? container will hot-reload.`)) return;
|
if (!(await themedConfirm({ message: `rebuild ${label}? container will hot-reload.`, confirmLabel: '↻ rebuild' }))) return;
|
||||||
closeOverflowMenu();
|
closeOverflowMenu();
|
||||||
const f = document.createElement('form');
|
const f = document.createElement('form');
|
||||||
f.method = 'POST';
|
f.method = 'POST';
|
||||||
|
|
@ -237,8 +194,11 @@ window.marked = marked;
|
||||||
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'),
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'),
|
||||||
'new claude session',
|
'new claude session',
|
||||||
);
|
);
|
||||||
newSessBtn.addEventListener('click', () => {
|
newSessBtn.addEventListener('click', async () => {
|
||||||
if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return;
|
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;
|
newSessBtn.disabled = true;
|
||||||
closeOverflowMenu();
|
closeOverflowMenu();
|
||||||
postNewSession().finally(() => { newSessBtn.disabled = false; });
|
postNewSession().finally(() => { newSessBtn.disabled = false; });
|
||||||
|
|
@ -263,14 +223,16 @@ window.marked = marked;
|
||||||
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '🔓'),
|
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '🔓'),
|
||||||
'logout',
|
'logout',
|
||||||
);
|
);
|
||||||
logoutBtn.addEventListener('click', () => {
|
logoutBtn.addEventListener('click', async () => {
|
||||||
if (!window.confirm(
|
if (!(await themedConfirm({
|
||||||
`log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
message:
|
||||||
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
`log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
||||||
`and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` +
|
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
||||||
`login screen. prior --continue session history is preserved — the agent picks up ` +
|
`and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` +
|
||||||
`where it left off on the next turn after re-login.`
|
`login screen. prior --continue session history is preserved — the agent picks up ` +
|
||||||
)) return;
|
`where it left off on the next turn after re-login.`,
|
||||||
|
danger: true, confirmLabel: '🔓 log out',
|
||||||
|
}))) return;
|
||||||
logoutBtn.disabled = true;
|
logoutBtn.disabled = true;
|
||||||
closeOverflowMenu();
|
closeOverflowMenu();
|
||||||
postLogout().finally(() => { logoutBtn.disabled = false; });
|
postLogout().finally(() => { logoutBtn.disabled = false; });
|
||||||
|
|
@ -641,20 +603,28 @@ window.marked = marked;
|
||||||
postCompact();
|
postCompact();
|
||||||
return true;
|
return true;
|
||||||
case '/new-session':
|
case '/new-session':
|
||||||
if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) {
|
// Fire the (async) themed confirm without blocking this function's
|
||||||
postNewSession();
|
// 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;
|
return true;
|
||||||
case '/logout':
|
case '/logout':
|
||||||
if (window.confirm(
|
(async () => {
|
||||||
`log out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
if (await themedConfirm({
|
||||||
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
message:
|
||||||
`and parks the agent in 'needs login' until you paste a fresh OAuth code from the ` +
|
`log out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
||||||
`login screen. prior --continue session history is preserved — the agent picks up ` +
|
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
||||||
`where it left off on the next turn after re-login.`
|
`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 ` +
|
||||||
postLogout();
|
`where it left off on the next turn after re-login.`,
|
||||||
}
|
danger: true, confirmLabel: '🔓 log out',
|
||||||
|
})) postLogout();
|
||||||
|
})();
|
||||||
return true;
|
return true;
|
||||||
case '/model': {
|
case '/model': {
|
||||||
const parts = trimmed.split(/\s+/);
|
const parts = trimmed.split(/\s+/);
|
||||||
|
|
@ -1046,13 +1016,14 @@ window.marked = marked;
|
||||||
+ 'history shown here is the most-recent-N regardless of state, '
|
+ 'history shown here is the most-recent-N regardless of state, '
|
||||||
+ 'so the list itself stays visible.',
|
+ 'so the list itself stays visible.',
|
||||||
}, '✓ mark all read');
|
}, '✓ mark all read');
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
|
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
|
||||||
if (!label) { status.textContent = 'agent label unknown'; return; }
|
if (!label) { status.textContent = 'agent label unknown'; return; }
|
||||||
if (!window.confirm(
|
if (!(await themedConfirm({
|
||||||
`mark every queued message for ${label} as read? `
|
message: `mark every queued message for ${label} as read? `
|
||||||
+ `the message history shown stays; only the unread queue is drained.`
|
+ `the message history shown stays; only the unread queue is drained.`,
|
||||||
)) return;
|
confirmLabel: '✓ mark all read',
|
||||||
|
}))) return;
|
||||||
status.textContent = 'clearing…';
|
status.textContent = 'clearing…';
|
||||||
asyncBtn(btn, async () => {
|
asyncBtn(btn, async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
// renderers here are direct copies from core.js / logs.js with only the
|
// renderers here are direct copies from core.js / logs.js with only the
|
||||||
// deep-link URL and count-pill id adjusted.
|
// 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 { fmtAgo, fmtElapsed, fmtDuration, truncate } from './util.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
||||||
// registers once via `initCall`.
|
// registers once via `initCall`.
|
||||||
|
|
||||||
import { $, el, form, Panel, appendLinkified } from './common.js';
|
import { $, form, Panel, appendLinkified } from './common.js';
|
||||||
import { themedToast } from './modal.js';
|
import { el } from '@hive/shared/dom.js';
|
||||||
|
import { themedToast } from '@hive/shared/modal.js';
|
||||||
import { epochSec, fmtAgo, fmtDuration } from './util.js';
|
import { epochSec, fmtAgo, fmtDuration } from './util.js';
|
||||||
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
|
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
@import "@hive/shared/tabs.css";
|
@import "@hive/shared/tabs.css";
|
||||||
@import "@hive/shared/chrome.css";
|
@import "@hive/shared/chrome.css";
|
||||||
@import "./modal.css";
|
@import "@hive/shared/modal.css";
|
||||||
|
|
||||||
/* ─── global typography ─────────────────────────────────────────────
|
/* ─── global typography ─────────────────────────────────────────────
|
||||||
Element-level rules shared across all three pages (index, flow,
|
Element-level rules shared across all three pages (index, flow,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,8 @@
|
||||||
// infrastructure for the side panel.
|
// infrastructure for the side panel.
|
||||||
|
|
||||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
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';
|
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 ────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────
|
||||||
export const $ = (id) => document.getElementById(id);
|
export const $ = (id) => document.getElementById(id);
|
||||||
|
|
@ -21,21 +17,6 @@ export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[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 = {}) => {
|
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
|
||||||
const f = el('form', {
|
const f = el('form', {
|
||||||
method: 'POST', action, class: 'inline', 'data-async': '',
|
method: 'POST', action, class: 'inline', 'data-async': '',
|
||||||
|
|
@ -52,70 +33,9 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
||||||
return f;
|
return f;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Page-level submit interceptor for `data-async` forms — the pattern every
|
// `bindAsyncForms` (the `data-async` form submit interceptor) now lives in
|
||||||
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
|
// `@hive/shared/forms.js` alongside `asyncBtn` — both the dashboard and the
|
||||||
// Without this a `data-async` form POSTs natively and the browser navigates
|
// per-agent UI import it directly from there rather than through this file.
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
|
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
|
||||||
// render helper live in the dashboard-internal `./util.js`, not here —
|
// render helper live in the dashboard-internal `./util.js`, not here —
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@
|
||||||
// (the same broker event channel the dashboard uses), maintaining its
|
// (the same broker event channel the dashboard uses), maintaining its
|
||||||
// own copy of the tombstones state.
|
// own copy of the tombstones state.
|
||||||
|
|
||||||
import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
|
import { $, form, openStream, initServerWarnings } from './common.js';
|
||||||
import { asyncBtn } from '@hive/shared/forms.js';
|
import { el } from '@hive/shared/dom.js';
|
||||||
|
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@
|
||||||
// themselves and pastes it in, same trust model as GITHUB.
|
// themselves and pastes it in, same trust model as GITHUB.
|
||||||
// Per-tab detail comments live next to their section below.
|
// 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 { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
import { themedConfirm, themedToast } from './modal.js';
|
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||||||
|
|
||||||
let agents = [];
|
let agents = [];
|
||||||
// agent name → container running (bool), from /api/state. Cross-referenced by
|
// agent name → container running (bool), from /api/state. Cross-referenced by
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@
|
||||||
|
|
||||||
import { create as termCreate } from '@hive/shared/terminal.js';
|
import { create as termCreate } from '@hive/shared/terminal.js';
|
||||||
import {
|
import {
|
||||||
$, el,
|
$,
|
||||||
NOTIF,
|
NOTIF,
|
||||||
appendLinkified,
|
appendLinkified,
|
||||||
openStream, initServerWarnings,
|
openStream, initServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
import { el } from '@hive/shared/dom.js';
|
||||||
import { epochSec } from './util.js';
|
import { epochSec } from './util.js';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@
|
||||||
// INFRA, and SYSTEM tabs so the operator knows how stale the output is.
|
// INFRA, and SYSTEM tabs so the operator knows how stale the output is.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
$, el, fmtAgeSecs, openStream, initServerWarnings,
|
$, fmtAgeSecs, openStream, initServerWarnings,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
import { el } from '@hive/shared/dom.js';
|
||||||
import { epochSec } from './util.js';
|
import { epochSec } from './util.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
|
// 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.
|
// 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 { containersState } from './state.js';
|
||||||
import { asyncBtn } from '@hive/shared/forms.js';
|
import { asyncBtn } from '@hive/shared/forms.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
// migration) and are no longer listed here — they surface via
|
// migration) and are no longer listed here — they surface via
|
||||||
// get_loose_ends / the todos pill on the agent page instead.
|
// get_loose_ends / the todos pill on the agent page instead.
|
||||||
|
|
||||||
import { $, el } from './common.js';
|
import { $ } 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 { paintAtomic, epochSec, fmtAgo, fmtDuration } from './util.js';
|
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from './util.js';
|
||||||
import { containersState } from './state.js';
|
import { containersState } from './state.js';
|
||||||
import { asyncBtn } from '@hive/shared/forms.js';
|
import { asyncBtn } from '@hive/shared/forms.js';
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@
|
||||||
// transient ops. See docs/web-ui.md::Container row for the rendering contract.
|
// transient ops. See docs/web-ui.md::Container row for the rendering contract.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
$, el, form, fmtAgeSecs,
|
$, form, fmtAgeSecs,
|
||||||
} from './common.js';
|
} 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 {
|
import {
|
||||||
containersState, questionsState,
|
containersState, questionsState,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,12 @@
|
||||||
|
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import {
|
import {
|
||||||
$, el,
|
$,
|
||||||
Panel, NOTIF,
|
Panel, NOTIF,
|
||||||
openStream, renderServerWarnings, bindAsyncForms,
|
openStream, renderServerWarnings,
|
||||||
} from './common.js';
|
} 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 { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
import {
|
import {
|
||||||
containersState, syncContainersFromSnapshot,
|
containersState, syncContainersFromSnapshot,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@
|
||||||
"./base.css": "./src/base.css",
|
"./base.css": "./src/base.css",
|
||||||
"./terminal.css": "./src/terminal.css",
|
"./terminal.css": "./src/terminal.css",
|
||||||
"./chrome.css": "./src/chrome.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": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
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
|
// Shared async-form primitives. Used by both the dashboard and the
|
||||||
// per-agent UI for any button that triggers a network action.
|
// 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:
|
// `asyncBtn(btn, fn)` — the single reusable component:
|
||||||
// 1. Guards double-click: returns immediately if `btn` is already
|
// 1. Guards double-click: returns immediately if `btn` is already
|
||||||
|
|
@ -34,3 +38,75 @@ export function asyncBtn(btn, fn) {
|
||||||
btn.innerHTML = orig;
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
/* modal.css — themed dialog component styles.
|
/* modal.css — themed dialog component styles.
|
||||||
Pairs with modal.js (themedConfirm / themedPrompt / themedToast). The
|
Pairs with modal.js (themedConfirm / themedPrompt / themedToast). The
|
||||||
dialogs are raised from common.js's data-async / data-confirm handler,
|
dialogs are raised from the shared `bindAsyncForms` data-async /
|
||||||
which every page loads, so common.css @imports this to keep the styles
|
data-confirm handler (forms.js), used by both the dashboard and the
|
||||||
present wherever a dialog can fire — not just the main dashboard.
|
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
|
(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 {
|
.tc-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
// modal.js — reusable themed modal/dialog component for the operator
|
// modal.js — reusable themed modal/dialog component, shared by the
|
||||||
// dashboard. An in-theme replacement for the browser's native
|
// dashboard and the per-agent UI. An in-theme replacement for the
|
||||||
// `confirm()` / `alert()` overlays so destructive actions and prompts match
|
// browser's native `confirm()` / `alert()` overlays so destructive
|
||||||
// the dashboard chrome instead of a jarring OS dialog.
|
// 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
|
// `openDialog` is the general primitive (any title/message/content + a row of
|
||||||
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
|
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
|
||||||
// checkboxes built on top of it. Styling lives in `dashboard.css` under the
|
// checkboxes built on top of it. Styling lives in `modal.css` under the
|
||||||
// `.tc-*` classes.
|
// `.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 })
|
// openDialog({ title, message, content, buttons, danger, dismissable })
|
||||||
// → Promise resolving to the clicked button's `value`, or `null` when the
|
// → Promise resolving to the clicked button's `value`, or `null` when the
|
||||||
Loading…
Reference in a new issue