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:
parent
ed92883294
commit
f7b19c9d56
4 changed files with 358 additions and 259 deletions
|
|
@ -0,0 +1,91 @@
|
|||
/* hive-agent-menu.css — scoped stylesheet for the <hive-agent-menu>
|
||||
shadow-DOM custom element (hive-agent-menu.js). Loaded as raw text at
|
||||
build time (esbuild's `text` loader) and appended as a <style> element
|
||||
inside the shadow root — see @hive/shared/shadow-css.js's header
|
||||
comment for why a plain <style> tag and not adoptedStyleSheets.
|
||||
|
||||
`:host` carries exactly the rules the old light-DOM `.agent-menu`
|
||||
wrapper div carried (flex:none, position:relative, ...) — the host
|
||||
element now plays that structural role directly, positioned after
|
||||
.card-body in .container-row's flex row (see dashboard.css). Its
|
||||
position:relative anchors the shadow tree's absolute-positioned
|
||||
.agent-menu-dropdown: a shadow host is the containing block for its
|
||||
own shadow tree's positioned descendants, exactly like any other
|
||||
positioned ancestor in the flat tree.
|
||||
|
||||
The hover-reveal opacity crosses the shadow boundary via the
|
||||
`--menu-btn-opacity` custom property (custom properties inherit
|
||||
through shadow boundaries): dashboard.css sets it to 1 on
|
||||
`.container-row:hover hive-agent-menu`; hive-agent-menu.js also sets
|
||||
it directly on the host's inline style while its own dropdown is
|
||||
open, since "am I open" is component-internal state a CSS selector
|
||||
out in the light DOM can't see. */
|
||||
|
||||
:host {
|
||||
flex: none;
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
.agent-menu-btn {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--subtext0);
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
opacity: var(--menu-btn-opacity, 0);
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
}
|
||||
.agent-menu-btn:hover,
|
||||
.agent-menu-btn:focus-visible {
|
||||
background: color-mix(in srgb, var(--purple) 10%, transparent);
|
||||
color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
.agent-menu-btn:focus-visible {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.agent-menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 2px);
|
||||
z-index: 50;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3em 0;
|
||||
min-width: 10em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.88em;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
padding: 0.4em 0.9em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.agent-menu-item:hover {
|
||||
background: var(--border);
|
||||
color: var(--purple);
|
||||
}
|
||||
.agent-menu-sep {
|
||||
height: 1px;
|
||||
background: var(--purple-dim);
|
||||
margin: 0.3em 0;
|
||||
padding: 0;
|
||||
}
|
||||
244
frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js
Normal file
244
frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
|
||||
// shadow-DOM custom element. One instance per agent card, appended after
|
||||
// .card-body in the container row's flex layout (swarm.js's
|
||||
// buildContainerLi via buildAgentMenu). The host element itself plays the
|
||||
// structural role the old light-DOM `.agent-menu` wrapper div played
|
||||
// (flex:none, position:relative — see hive-agent-menu.css's `:host` rule),
|
||||
// so its shadow tree's absolute-positioned dropdown anchors off it same as
|
||||
// before.
|
||||
//
|
||||
// Callers set `._opts = { c, forgeBase }` before appending — custom
|
||||
// elements can't take constructor args via `document.createElement`
|
||||
// (same convention `<hive-dialog>` uses, see modal.js).
|
||||
//
|
||||
// At most one instance's dropdown is open at a time. `openInstances`
|
||||
// tracks every instance currently open (a Set, though in practice it never
|
||||
// holds more than one) so `closeAll()` can close each one via its own
|
||||
// `.close()` instance method rather than reaching into another instance's
|
||||
// shadow internals from outside. `closeAllAgentMenus()` is exported for
|
||||
// swarm.js's buildAgentTree to call before it replaces the container tree
|
||||
// DOM (the previous dropdown element would otherwise be a stale
|
||||
// reference).
|
||||
//
|
||||
// The document-level click/keydown listeners below are registered once at
|
||||
// module scope (not per-instance, and not per open/close) — shadow-DOM
|
||||
// event retargeting means `e.target` for a click that lands inside one
|
||||
// instance's shadow tree, once the event bubbles past that instance's own
|
||||
// host, is retargeted to that host rather than the actual element clicked.
|
||||
// `e.composedPath()` sees the real, unretargeted path, so it's what's used
|
||||
// here to test "did this click land inside any open menu's shadow tree" —
|
||||
// same reasoning as `<hive-dialog>`'s backdrop-click check.
|
||||
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||||
import { attachShadowCss } from '@hive/shared/shadow-css.js';
|
||||
import agentMenuCss from './hive-agent-menu.css';
|
||||
|
||||
const openInstances = new Set();
|
||||
|
||||
function closeAll() {
|
||||
for (const inst of [...openInstances]) inst.close();
|
||||
}
|
||||
|
||||
// Exported for swarm.js's buildAgentTree — see module header.
|
||||
export function closeAllAgentMenus() {
|
||||
closeAll();
|
||||
}
|
||||
|
||||
// Close on any click outside every currently-open instance.
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!openInstances.size) return;
|
||||
const path = e.composedPath();
|
||||
for (const inst of [...openInstances]) {
|
||||
if (!path.includes(inst)) inst.close();
|
||||
}
|
||||
}, true);
|
||||
// Close on Escape. stopImmediatePropagation so swarm.js's own
|
||||
// selection-clear Escape handler doesn't also fire while a menu is open.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && openInstances.size) {
|
||||
closeAll();
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
class HiveAgentMenu extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const { c, forgeBase } = this._opts || {};
|
||||
const root = attachShadowCss(this, agentMenuCss);
|
||||
|
||||
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' });
|
||||
this._btn = btn;
|
||||
this._dropdown = dropdown;
|
||||
|
||||
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 () => {
|
||||
this.close();
|
||||
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;
|
||||
};
|
||||
|
||||
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', () => this.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}?` }),
|
||||
);
|
||||
}
|
||||
// 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 wasOpen = openInstances.has(this);
|
||||
closeAll();
|
||||
if (!wasOpen) this.open();
|
||||
});
|
||||
|
||||
root.append(btn, dropdown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Guards against a stale entry if the element is removed from the DOM
|
||||
// (row re-render, tab switch) while its dropdown was still open.
|
||||
openInstances.delete(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
this._dropdown.hidden = false;
|
||||
this._btn.setAttribute('aria-expanded', 'true');
|
||||
this.style.setProperty('--menu-btn-opacity', '1');
|
||||
openInstances.add(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!openInstances.has(this)) return;
|
||||
this._dropdown.hidden = true;
|
||||
this._btn.setAttribute('aria-expanded', 'false');
|
||||
this.style.removeProperty('--menu-btn-opacity');
|
||||
openInstances.delete(this);
|
||||
}
|
||||
}
|
||||
customElements.define('hive-agent-menu', HiveAgentMenu);
|
||||
|
|
@ -279,78 +279,19 @@ body.dashboard-shell {
|
|||
}
|
||||
|
||||
/* ── per-agent three-dot context menu ─────────────────────────────────────
|
||||
Positioned after .card-body in the flex row. */
|
||||
.agent-menu {
|
||||
flex: none;
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
.agent-menu-btn {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--subtext0);
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
}
|
||||
.container-row:hover .agent-menu-btn,
|
||||
.agent-menu.open .agent-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.agent-menu-btn:hover,
|
||||
.agent-menu-btn:focus-visible {
|
||||
background: color-mix(in srgb, var(--purple) 10%, transparent);
|
||||
color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
.agent-menu-btn:focus-visible {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.agent-menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 2px);
|
||||
z-index: 50;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3em 0;
|
||||
min-width: 10em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.88em;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
padding: 0.4em 0.9em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.agent-menu-item:hover {
|
||||
background: var(--border);
|
||||
color: var(--purple);
|
||||
}
|
||||
.agent-menu-sep {
|
||||
height: 1px;
|
||||
background: var(--purple-dim);
|
||||
margin: 0.3em 0;
|
||||
padding: 0;
|
||||
Positioned after .card-body in the flex row. The menu itself is the
|
||||
<hive-agent-menu> shadow-DOM custom element (agent-menu/hive-agent-menu.js
|
||||
+ .css) — its own shadow-scoped stylesheet carries the button/dropdown/
|
||||
item/separator rules and the `:host` rules that play the structural
|
||||
role this light-DOM wrapper used to play. The one rule that still has
|
||||
to live out here is the hover reveal: a light-DOM descendant-combinator
|
||||
selector can't reach into the shadow tree, so it's relayed across the
|
||||
shadow boundary via the `--menu-btn-opacity` custom property (custom
|
||||
properties inherit through shadow boundaries) instead — the
|
||||
component's own JS forces the same variable to 1 while its dropdown is
|
||||
open (see hive-agent-menu.js's open()/close()). */
|
||||
.container-row:hover hive-agent-menu {
|
||||
--menu-btn-opacity: 1;
|
||||
}
|
||||
/* Pending state splits queued vs running. */
|
||||
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue