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
|
|
@ -3,7 +3,9 @@
|
|||
// actions (send / login/* / dashboard rebuild).
|
||||
|
||||
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 DOMPurify from 'dompurify';
|
||||
|
||||
|
|
@ -22,19 +24,6 @@ window.marked = marked;
|
|||
const escText = (s) => String(s).replace(/[&<>"]/g, (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
|
||||
// /api/state lands. Operator-authority actions (answering a question
|
||||
|
|
@ -43,41 +32,9 @@ window.marked = marked;
|
|||
let dashboardBase = '';
|
||||
|
||||
// ─── async-form submit (shared with dashboard) ──────────────────────────
|
||||
document.addEventListener('submit', async (e) => {
|
||||
const f = e.target;
|
||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||
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; }
|
||||
}
|
||||
});
|
||||
// Themed confirm/prompt/toast dialogs + the asyncBtn spinner instead of
|
||||
// native confirm()/alert() dialogs that broke out of the page theme.
|
||||
bindAsyncForms(() => refreshState());
|
||||
|
||||
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
|
||||
// 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' }, '↻'),
|
||||
'rebuild container',
|
||||
);
|
||||
rebuildBtn.addEventListener('click', () => {
|
||||
if (!window.confirm(`rebuild ${label}? container will hot-reload.`)) return;
|
||||
rebuildBtn.addEventListener('click', async () => {
|
||||
if (!(await themedConfirm({ message: `rebuild ${label}? container will hot-reload.`, confirmLabel: '↻ rebuild' }))) return;
|
||||
closeOverflowMenu();
|
||||
const f = document.createElement('form');
|
||||
f.method = 'POST';
|
||||
|
|
@ -237,8 +194,11 @@ window.marked = marked;
|
|||
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '↻'),
|
||||
'new claude session',
|
||||
);
|
||||
newSessBtn.addEventListener('click', () => {
|
||||
if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return;
|
||||
newSessBtn.addEventListener('click', 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',
|
||||
}))) return;
|
||||
newSessBtn.disabled = true;
|
||||
closeOverflowMenu();
|
||||
postNewSession().finally(() => { newSessBtn.disabled = false; });
|
||||
|
|
@ -263,14 +223,16 @@ window.marked = marked;
|
|||
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '🔓'),
|
||||
'logout',
|
||||
);
|
||||
logoutBtn.addEventListener('click', () => {
|
||||
if (!window.confirm(
|
||||
`log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
||||
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
||||
`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 ` +
|
||||
`where it left off on the next turn after re-login.`
|
||||
)) return;
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
if (!(await themedConfirm({
|
||||
message:
|
||||
`log ${label} out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
||||
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
||||
`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 ` +
|
||||
`where it left off on the next turn after re-login.`,
|
||||
danger: true, confirmLabel: '🔓 log out',
|
||||
}))) return;
|
||||
logoutBtn.disabled = true;
|
||||
closeOverflowMenu();
|
||||
postLogout().finally(() => { logoutBtn.disabled = false; });
|
||||
|
|
@ -641,20 +603,28 @@ window.marked = marked;
|
|||
postCompact();
|
||||
return true;
|
||||
case '/new-session':
|
||||
if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) {
|
||||
postNewSession();
|
||||
}
|
||||
// Fire the (async) themed confirm without blocking this function's
|
||||
// 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;
|
||||
case '/logout':
|
||||
if (window.confirm(
|
||||
`log out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
||||
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
||||
`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 ` +
|
||||
`where it left off on the next turn after re-login.`
|
||||
)) {
|
||||
postLogout();
|
||||
}
|
||||
(async () => {
|
||||
if (await themedConfirm({
|
||||
message:
|
||||
`log out? this SIGINTs any running claude turn, deletes only the OAuth ` +
|
||||
`credential files (.credentials.json + mcp-needs-auth-cache.json) in ~/.claude/, ` +
|
||||
`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 ` +
|
||||
`where it left off on the next turn after re-login.`,
|
||||
danger: true, confirmLabel: '🔓 log out',
|
||||
})) postLogout();
|
||||
})();
|
||||
return true;
|
||||
case '/model': {
|
||||
const parts = trimmed.split(/\s+/);
|
||||
|
|
@ -1046,13 +1016,14 @@ window.marked = marked;
|
|||
+ 'history shown here is the most-recent-N regardless of state, '
|
||||
+ 'so the list itself stays visible.',
|
||||
}, '✓ mark all read');
|
||||
btn.addEventListener('click', () => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
|
||||
if (!label) { status.textContent = 'agent label unknown'; return; }
|
||||
if (!window.confirm(
|
||||
`mark every queued message for ${label} as read? `
|
||||
+ `the message history shown stays; only the unread queue is drained.`
|
||||
)) return;
|
||||
if (!(await themedConfirm({
|
||||
message: `mark every queued message for ${label} as read? `
|
||||
+ `the message history shown stays; only the unread queue is drained.`,
|
||||
confirmLabel: '✓ mark all read',
|
||||
}))) return;
|
||||
status.textContent = 'clearing…';
|
||||
asyncBtn(btn, async () => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue