207 lines
8.2 KiB
JavaScript
207 lines
8.2 KiB
JavaScript
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
|
|
// custom element. One instance per agent card, appended after
|
|
// .card-body in the container row's flex layout (swarm.js's
|
|
// buildContainerLi via buildAgentMenu).
|
|
//
|
|
// Deliberately a *light-DOM* element with no shadow root of its own: all
|
|
// the generic dropdown-menu mechanics (shadow attach, open/close state,
|
|
// positioning, singleton coordination, outside-click/Escape) now live in
|
|
// the shared `<hive-menu>` component (@hive/shared/hive-menu.js) — this
|
|
// element's whole job is building the agent-specific trigger button +
|
|
// item list and handing them to an internal `<hive-menu>` via its
|
|
// `._opts = { trigger, content }` contract, same convention `<hive-
|
|
// dialog>` uses (custom elements can't take constructor args via
|
|
// `document.createElement`). Its own shadow root would've bought nothing
|
|
// once `<hive-menu>` owns shadow/positioning/open-close, so it doesn't
|
|
// have one — which means its `.agent-menu-*` classes need a stylesheet
|
|
// that actually reaches them. Since `<hive-menu>` slots this element's
|
|
// nodes in rather than moving them into its own shadow root (see
|
|
// hive-menu.js's header for why), they stay in the light DOM the whole
|
|
// way down — reachable by dashboard.css's ordinary global rules (there's
|
|
// no hive-agent-menu.css anymore; its old rules moved there, minus the
|
|
// pure positioning rule which is now generic and lives in `<hive-menu>`'s
|
|
// own shadow-scoped `.menu-dropdown`).
|
|
|
|
import { el } from '@hive/shared/dom.js';
|
|
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
|
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
|
|
|
|
// Single-agent POST helper shared by all menu items. `flags` is an object
|
|
// of boolean query params to set truthy (e.g. `{ graceful: true }` or
|
|
// `{ paused: true }`) — every menu action so far needs at most one, but
|
|
// this stays a plain object rather than a single named arg so a future
|
|
// action needing two doesn't have to touch this signature again.
|
|
async function agentMenuPost(actionPath, name, body, flags) {
|
|
const params = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(flags || {})) {
|
|
if (v) params.set(k, 'true');
|
|
}
|
|
const qs = params.toString();
|
|
const url = actionPath + encodeURIComponent(name) + (qs ? '?' + qs : '');
|
|
try {
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
|
|
body: body ? new URLSearchParams(body) : undefined,
|
|
redirect: 'manual',
|
|
});
|
|
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' });
|
|
}
|
|
} catch (err) {
|
|
themedToast('action failed: ' + err, { type: 'error' });
|
|
}
|
|
}
|
|
|
|
class HiveAgentMenu extends HTMLElement {
|
|
connectedCallback() {
|
|
// Same reconnect-without-detach hazard as <hive-menu> (see its
|
|
// connectedCallback comment) — swarm.js's row cache can move an
|
|
// already-built <li> subtree without a real detach. Without this
|
|
// guard a reconnect would append a second <hive-menu> on top of the
|
|
// first, doubling the dropdown.
|
|
if (this._menu) return;
|
|
|
|
const { c, forgeBase } = this._opts || {};
|
|
|
|
const btn = el('button', {
|
|
type: 'button',
|
|
class: 'agent-menu-btn',
|
|
title: `actions for ${c.name}`,
|
|
'aria-label': `actions for ${c.name}`,
|
|
'aria-haspopup': 'menu',
|
|
'aria-expanded': 'false',
|
|
}, '⋮');
|
|
const dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' });
|
|
|
|
const close = () => this._menu.close();
|
|
|
|
const menuItem = (label, opts) => {
|
|
const li = el('li', { role: 'presentation' });
|
|
const item = el('button', {
|
|
type: 'button',
|
|
class: 'agent-menu-item',
|
|
role: 'menuitem',
|
|
}, label);
|
|
item.addEventListener('click', async () => {
|
|
close();
|
|
let flags = {};
|
|
if (opts.confirm) {
|
|
const checkboxes = [];
|
|
if (opts.graceful) {
|
|
checkboxes.push({ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' });
|
|
}
|
|
if (opts.paused) {
|
|
checkboxes.push({ name: 'paused', label: opts.pausedLabel || 'start paused — come up without driving turns until resumed' });
|
|
}
|
|
const r = await themedConfirm({
|
|
message: opts.confirm,
|
|
danger: true,
|
|
confirmLabel: opts.confirmLabel || 'confirm',
|
|
checkboxes,
|
|
});
|
|
if (!r) return;
|
|
flags = { graceful: !!r.graceful, paused: !!r.paused };
|
|
}
|
|
await agentMenuPost(opts.action, c.name, opts.body || null, flags);
|
|
});
|
|
li.append(item);
|
|
return li;
|
|
};
|
|
|
|
const menuSep = () => el('li', { class: 'agent-menu-sep', role: 'separator' });
|
|
|
|
// Navigation link item (opens in same tab by default).
|
|
const menuLink = (label, href, title) => {
|
|
const li = el('li', { role: 'presentation' });
|
|
const a = el('a', {
|
|
class: 'agent-menu-item',
|
|
href,
|
|
role: 'menuitem',
|
|
title: title || '',
|
|
}, label);
|
|
a.addEventListener('click', close);
|
|
li.append(a);
|
|
return li;
|
|
};
|
|
|
|
// Show only actions that are applicable in the current state.
|
|
if (c.running) {
|
|
dropdown.append(
|
|
menuItem('↺ R3ST4RT', {
|
|
action: '/api/restart/',
|
|
confirm: `restart ${c.name}?`,
|
|
graceful: true,
|
|
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
|
|
}),
|
|
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
|
|
);
|
|
} else {
|
|
dropdown.append(
|
|
menuItem('▶ ST4RT', {
|
|
action: '/api/start/',
|
|
confirm: `start ${c.name}?`,
|
|
paused: true,
|
|
pausedLabel: 'start paused — come up without driving turns until resumed',
|
|
}),
|
|
);
|
|
}
|
|
// Pause/resume is orthogonal to running: a paused stopped agent boots
|
|
// paused; a paused running agent keeps its container but drives no turns.
|
|
if (c.paused) {
|
|
dropdown.append(
|
|
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }),
|
|
);
|
|
} else {
|
|
dropdown.append(
|
|
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }),
|
|
);
|
|
}
|
|
dropdown.append(
|
|
menuSep(),
|
|
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
|
|
menuSep(),
|
|
// Deep-link to the AGENT log tab pre-filtered to this container.
|
|
// The ?agent= param is read by logs.js on load and pre-selects this
|
|
// agent's journal without extra clicks.
|
|
menuLink('journal logs →',
|
|
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
|
|
`view ${c.name} journal logs`),
|
|
);
|
|
|
|
dropdown.append(
|
|
menuSep(),
|
|
menuItem('DESTR0Y', {
|
|
action: '/api/destroy/',
|
|
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
|
|
}),
|
|
menuItem('PURG3', {
|
|
action: '/api/destroy/',
|
|
body: { purge: 'on' },
|
|
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
|
|
}),
|
|
);
|
|
|
|
if (c.deployed_sha && forgeBase) {
|
|
const li = el('li', { role: 'presentation' });
|
|
const a = el('a', {
|
|
class: 'agent-menu-item',
|
|
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
|
|
target: '_blank',
|
|
rel: 'noopener',
|
|
role: 'menuitem',
|
|
title: 'deployed config commit on forge',
|
|
}, `deployed:${c.deployed_sha} ↗`);
|
|
li.append(a);
|
|
dropdown.append(menuSep(), li);
|
|
}
|
|
|
|
this._menu = document.createElement('hive-menu');
|
|
this._menu._opts = { trigger: btn, content: dropdown };
|
|
this.append(this._menu);
|
|
}
|
|
}
|
|
customElements.define('hive-agent-menu', HiveAgentMenu);
|