// hive-agent-menu.js — , 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 `` 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 `` via its // `._opts = { trigger, content }` contract, same convention `` uses (custom elements can't take constructor args via // `document.createElement`). Its own shadow root would've bought nothing // once `` 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 `` 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 ``'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 — 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 (see its // connectedCallback comment) — swarm.js's row cache can move an // already-built
  • subtree without a real detach. Without this // guard a reconnect would append a second 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);