dashboard: convert the per-agent ⋮ context menu to a hive-agent-menu shadow-DOM component

Moves buildAgentMenu's DOM-building body, the menuItem/menuSep/menuLink
helpers, agentMenuPost, and the open-dropdown coordination logic out of
swarm.js and into a new <hive-agent-menu> autonomous custom element
(dashboard/src/agent-menu/), following the same shadow-DOM + one-dir-per-
component shape as hive-dialog. swarm.js's buildAgentMenu is now a thin
wrapper that constructs the element and sets ._opts before appending it,
same convention hive-dialog uses since a custom element created via
document.createElement can't take constructor args.

The module-level "one dropdown open at a time" singleton (previously a
single mutable variable in swarm.js) becomes a tracked Set of open
instances inside the component module; each instance closes itself via
its own close() method rather than another instance reaching into its
shadow internals. The document-level outside-click and Escape listeners
move into the component module too, keyed off e.composedPath() instead of
e.target.closest() -- shadow-DOM event retargeting means a plain
e.target check no longer reliably reaches into a specific instance's
shadow tree. closeAllAgentMenus() is exported for swarm.js's
buildAgentTree, which still needs to close any open menu before it
replaces the container tree DOM.

The hover-reveal opacity rule crosses the shadow boundary via a
--menu-btn-opacity custom property (custom properties inherit through
shadow boundaries): dashboard.css sets it on hover of the light-DOM
hive-agent-menu element, and the component sets it directly from JS while
its own dropdown is open, since that's component-internal state a CSS
selector out in the light DOM can't see. The host element itself takes on
the structural role (flex:none, position:relative, ...) the old
light-DOM .agent-menu wrapper div played, since its shadow tree's
absolute-positioned dropdown needs a positioned ancestor to anchor off of.

Verified end to end with a standalone esbuild-bundled test harness run
under headless chromium: row layout/flex sizing, hover-reveal opacity,
and dropdown positioning all render correctly, and a scripted interaction
pass (singleton exclusivity, outside-click close, Escape close, toggle
behavior, menu-item click close, and the exported closeAllAgentMenus())
all pass.
This commit is contained in:
iris 2026-07-31 22:15:05 +02:00 committed by mara
commit f7b19c9d56
4 changed files with 358 additions and 259 deletions

View file

@ -11,6 +11,7 @@ import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import {
containersState, questionsState,
} from './state.js';
import { closeAllAgentMenus } from './agent-menu/hive-agent-menu.js';
// Context-window badge thresholds. Preferred source is each container's
// `context_window_tokens` from /api/state (the real window for the model
@ -45,8 +46,6 @@ const transientsState = new Map();
// tab-gated visibility).
const selectionState = new Set();
let openAgentMenu = null; // currently open dropdown element, or null
// ─── rebuild queue ──────────────────────────────────────────────────────────
export function syncRebuildQueueFromSnapshot(s) {
@ -186,192 +185,16 @@ document.addEventListener('click', (e) => {
// stopped; rebuild + destroy/purge always shown.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
function closeAllAgentMenus() {
if (!openAgentMenu) return;
openAgentMenu.hidden = true;
const wrap = openAgentMenu.closest('.agent-menu');
if (wrap) {
wrap.classList.remove('open');
const btn = wrap.querySelector('.agent-menu-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
openAgentMenu = null;
}
// Close on any click outside an agent-menu element.
document.addEventListener('click', (e) => {
if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
}, true);
// Close on Escape. stopImmediatePropagation so the selection-clear
// handler on the same element doesn't also fire when a menu is open.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openAgentMenu) {
closeAllAgentMenus();
e.stopImmediatePropagation();
}
}, true);
// Single-agent POST helper shared by all menu items.
async function agentMenuPost(actionPath, name, body, graceful) {
const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
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' });
}
}
// Rendering + all interaction/coordination logic lives in the
// <hive-agent-menu> shadow-DOM custom element (./agent-menu/hive-agent-menu.js,
// imported above for its `closeAllAgentMenus` export and its
// customElements.define side effect); this is a thin wrapper matching
// <hive-dialog>'s `._opts`-before-append convention, since a custom
// element created via `document.createElement` can't take constructor args.
function buildAgentMenu(c, forgeBase) {
const wrap = el('div', { class: 'agent-menu' });
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', hidden: true, role: 'menu' });
function 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 () => {
closeAllAgentMenus();
let graceful = false;
if (opts.confirm) {
const r = await themedConfirm({
message: opts.confirm,
danger: true,
confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful
? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
: [],
});
if (!r) return;
graceful = !!r.graceful;
}
await agentMenuPost(opts.action, c.name, opts.body || null, graceful);
});
li.append(item);
return li;
}
function menuSep() {
return el('li', { class: 'agent-menu-sep', role: 'separator' });
}
// Navigation link item (opens in same tab by default).
function 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', () => closeAllAgentMenus());
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}?` }),
);
}
// 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);
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wasHidden = dropdown.hidden;
closeAllAgentMenus();
if (wasHidden) {
dropdown.hidden = false;
btn.setAttribute('aria-expanded', 'true');
wrap.classList.add('open');
openAgentMenu = dropdown;
}
});
wrap.append(btn, dropdown);
return wrap;
const menu = document.createElement('hive-agent-menu');
menu._opts = { c, forgeBase };
return menu;
}
// ─── port conflicts ──────────────────────────────────────────────────────────