Compare commits
6 changed files with 228 additions and 404 deletions
|
|
@ -1,181 +0,0 @@
|
||||||
// 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.
|
|
||||||
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 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 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', 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._menu = document.createElement('hive-menu');
|
|
||||||
this._menu._opts = { trigger: btn, content: dropdown };
|
|
||||||
this.append(this._menu);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customElements.define('hive-agent-menu', HiveAgentMenu);
|
|
||||||
|
|
@ -279,41 +279,53 @@ body.dashboard-shell {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── per-agent three-dot context menu ─────────────────────────────────────
|
/* ── per-agent three-dot context menu ─────────────────────────────────────
|
||||||
Positioned after .card-body in the flex row. `<hive-agent-menu>`
|
Positioned after .card-body in the flex row. */
|
||||||
(agent-menu/hive-agent-menu.js) is a light-DOM element with no shadow
|
.agent-menu {
|
||||||
root of its own — the generic dropdown mechanics (shadow attach,
|
|
||||||
open/close, positioning) live in the shared `<hive-menu>` component
|
|
||||||
instead (@hive/shared/hive-menu.js), which slots this element's
|
|
||||||
trigger/content nodes into position rather than moving them into its
|
|
||||||
own shadow root. That keeps these nodes in the ordinary light DOM the
|
|
||||||
whole way down, so this ordinary global stylesheet reaches them
|
|
||||||
directly — see hive-agent-menu.js's header for the full reasoning.
|
|
||||||
`hive-agent-menu` itself just needs the flex/alignment role the old
|
|
||||||
light-DOM `.agent-menu` wrapper div played in this row; the
|
|
||||||
`position: relative` an absolutely-positioned dropdown needs now lives
|
|
||||||
on `<hive-menu>`'s own `:host` instead, since that's the element whose
|
|
||||||
shadow tree the dropdown is actually positioned within. */
|
|
||||||
hive-agent-menu {
|
|
||||||
flex: none;
|
flex: none;
|
||||||
|
position: relative;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: 0.3em;
|
margin-top: 0.3em;
|
||||||
}
|
}
|
||||||
/* .agent-menu-btn carries no styling of its own anymore — the trigger is
|
.agent-menu-btn {
|
||||||
a top-level `slot="trigger"` node inside <hive-menu>'s shadow tree, so
|
display: block;
|
||||||
its base icon-button chrome now lives in hive-menu.css's
|
background: none;
|
||||||
`::slotted([slot='trigger'])` rules instead (reachable and genuinely
|
border: none;
|
||||||
generic — any <hive-menu> trigger gets the same treatment). The class
|
color: var(--subtext0);
|
||||||
itself stays, just as an identification hook (aria-label/title
|
font-size: 1.1em;
|
||||||
already carry the real semantics). */
|
line-height: 1;
|
||||||
/* Box chrome (background/border/radius/shadow/min-width/white-space) now
|
cursor: pointer;
|
||||||
lives on <hive-menu>'s own `.menu-dropdown` wrapper (shadow-DOM-owned
|
padding: 0.1em 0.4em;
|
||||||
markup, no slotting constraint) — this is just the `<ul>`'s own
|
border-radius: 4px;
|
||||||
list-reset + item padding, which only the caller (owning the actual
|
opacity: 0;
|
||||||
list markup) can set. */
|
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 {
|
.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;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.3em 0;
|
padding: 0.3em 0;
|
||||||
|
min-width: 10em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.agent-menu-item {
|
.agent-menu-item {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -340,13 +352,6 @@ hive-agent-menu {
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
/* Hover reveal: `--menu-btn-opacity` is forced to 1 while a hover
|
|
||||||
selector (or the trigger's own open state, set by <hive-menu>) applies
|
|
||||||
— inherits down through `hive-agent-menu` → `<hive-menu>` → the
|
|
||||||
trigger button same as any custom property, shadow trees included. */
|
|
||||||
.container-row:hover hive-agent-menu {
|
|
||||||
--menu-btn-opacity: 1;
|
|
||||||
}
|
|
||||||
/* Pending state splits queued vs running. */
|
/* Pending state splits queued vs running. */
|
||||||
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
|
.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
|
||||||
.container-row.pending-running {
|
.container-row.pending-running {
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||||||
import {
|
import {
|
||||||
containersState, questionsState,
|
containersState, questionsState,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
import { closeAllMenus } from '@hive/shared/hive-menu.js';
|
|
||||||
import './agent-menu/hive-agent-menu.js'; // registers <hive-agent-menu> — side-effect import
|
|
||||||
|
|
||||||
// Context-window badge thresholds. Preferred source is each container's
|
// Context-window badge thresholds. Preferred source is each container's
|
||||||
// `context_window_tokens` from /api/state (the real window for the model
|
// `context_window_tokens` from /api/state (the real window for the model
|
||||||
|
|
@ -47,6 +45,8 @@ const transientsState = new Map();
|
||||||
// tab-gated visibility).
|
// tab-gated visibility).
|
||||||
const selectionState = new Set();
|
const selectionState = new Set();
|
||||||
|
|
||||||
|
let openAgentMenu = null; // currently open dropdown element, or null
|
||||||
|
|
||||||
// ─── rebuild queue ──────────────────────────────────────────────────────────
|
// ─── rebuild queue ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function syncRebuildQueueFromSnapshot(s) {
|
export function syncRebuildQueueFromSnapshot(s) {
|
||||||
|
|
@ -186,19 +186,192 @@ document.addEventListener('click', (e) => {
|
||||||
// stopped; rebuild + destroy/purge always shown.
|
// stopped; rebuild + destroy/purge always shown.
|
||||||
// The button is CSS-invisible until the row is hovered (or menu is
|
// The button is CSS-invisible until the row is hovered (or menu is
|
||||||
// open) so it doesn't clutter quiet rows.
|
// open) so it doesn't clutter quiet rows.
|
||||||
// Rendering + agent-specific interaction lives in the <hive-agent-menu>
|
|
||||||
// custom element (./agent-menu/hive-agent-menu.js, imported above for its
|
function closeAllAgentMenus() {
|
||||||
// customElements.define side effect); the generic dropdown mechanics
|
if (!openAgentMenu) return;
|
||||||
// (open/close, positioning, singleton coordination — closed via
|
openAgentMenu.hidden = true;
|
||||||
// `closeAllMenus` below) live in the shared `<hive-menu>` component it
|
const wrap = openAgentMenu.closest('.agent-menu');
|
||||||
// composes internally (@hive/shared/hive-menu.js). This is a thin
|
if (wrap) {
|
||||||
// wrapper matching <hive-dialog>'s `._opts`-before-append convention,
|
wrap.classList.remove('open');
|
||||||
// since a custom element created via `document.createElement` can't
|
const btn = wrap.querySelector('.agent-menu-btn');
|
||||||
// take constructor args.
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildAgentMenu(c, forgeBase) {
|
function buildAgentMenu(c, forgeBase) {
|
||||||
const menu = document.createElement('hive-agent-menu');
|
const wrap = el('div', { class: 'agent-menu' });
|
||||||
menu._opts = { c, forgeBase };
|
const btn = el('button', {
|
||||||
return menu;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── port conflicts ──────────────────────────────────────────────────────────
|
// ─── port conflicts ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -230,7 +403,7 @@ function derivePortConflicts(containers) {
|
||||||
function buildAgentTree(containers) {
|
function buildAgentTree(containers) {
|
||||||
// Close any open context menu before replacing the DOM tree — the
|
// Close any open context menu before replacing the DOM tree — the
|
||||||
// previous dropdown element would otherwise be a stale reference.
|
// previous dropdown element would otherwise be a stale reference.
|
||||||
closeAllMenus();
|
closeAllAgentMenus();
|
||||||
const byName = new Map();
|
const byName = new Map();
|
||||||
for (const c of containers) byName.set(c.name, c);
|
for (const c of containers) byName.set(c.name, c);
|
||||||
const children = new Map(); // parent_name -> [child_name, ...]
|
const children = new Map(); // parent_name -> [child_name, ...]
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,7 @@
|
||||||
"./chrome.css": "./src/chrome.css",
|
"./chrome.css": "./src/chrome.css",
|
||||||
"./forms.js": "./src/forms.js",
|
"./forms.js": "./src/forms.js",
|
||||||
"./dom.js": "./src/dom.js",
|
"./dom.js": "./src/dom.js",
|
||||||
"./modal.js": "./src/modal.js",
|
"./modal.js": "./src/modal.js"
|
||||||
"./shadow-css.js": "./src/shadow-css.js",
|
|
||||||
"./hive-menu.js": "./src/hive-menu/hive-menu.js"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
/* hive-menu.css — scoped stylesheet for the generic <hive-menu>
|
|
||||||
shadow-DOM custom element (hive-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.
|
|
||||||
|
|
||||||
Two kinds of rules live here, split by what they can actually reach:
|
|
||||||
`:host`/`.menu-dropdown` style shadow-DOM-owned markup this element
|
|
||||||
builds itself (no slotting constraint at all); `::slotted(...)` rules
|
|
||||||
reach the caller's *top-level* trigger/content nodes (the ones with a
|
|
||||||
`slot` attribute) since those are true light-DOM children, just
|
|
||||||
rendered here. What can NOT live here: anything inside the content
|
|
||||||
node (e.g. individual dropdown item rows) — `::slotted()` only
|
|
||||||
matches directly-slotted elements, not their descendants, and no CSS
|
|
||||||
mechanism pierces further than that. That's a hard Shadow DOM
|
|
||||||
architecture limit, not a scope choice — item-level styling stays in
|
|
||||||
the caller's own stylesheet regardless of how generic it looks. */
|
|
||||||
|
|
||||||
:host {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.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);
|
|
||||||
min-width: 10em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
/* The trigger is the top-level `slot="trigger"` node — reachable, so its
|
|
||||||
base icon-button chrome (invisible until hover/open, via
|
|
||||||
`--menu-btn-opacity`) lives here rather than duplicated per caller. */
|
|
||||||
::slotted([slot='trigger']) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
::slotted([slot='trigger']:hover),
|
|
||||||
::slotted([slot='trigger']:focus-visible) {
|
|
||||||
background: color-mix(in srgb, var(--purple) 10%, transparent);
|
|
||||||
color: var(--purple);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
::slotted([slot='trigger']:focus-visible) {
|
|
||||||
outline: 1px solid var(--purple);
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
// hive-menu.js — <hive-menu>, the generic dropdown-menu shadow-DOM custom
|
|
||||||
// element behind any "click a trigger, get a positioned dropdown" UI
|
|
||||||
// (currently just <hive-agent-menu>'s per-agent "⋮" menu). Owns
|
|
||||||
// open/close state, trigger + dropdown positioning, singleton
|
|
||||||
// close-on-open coordination across every `<hive-menu>` instance in the
|
|
||||||
// app, and the document-level outside-click/Escape listeners. Doesn't
|
|
||||||
// know or care what's inside the trigger/dropdown — opaque DOM nodes the
|
|
||||||
// caller hands over via `._opts = { trigger, content }` before appending
|
|
||||||
// (same `._opts`-before-append convention `<hive-dialog>` uses).
|
|
||||||
//
|
|
||||||
// `trigger`/`content` are appended as *light-DOM* children, projected
|
|
||||||
// into the shadow template via named `<slot>`s rather than moved into
|
|
||||||
// the shadow root. Load-bearing: it keeps the caller's own class-scoped
|
|
||||||
// styling applying to the nodes it built — a `<style>` only styles
|
|
||||||
// elements within the same tree it's part of, and slotted content keeps
|
|
||||||
// the tree membership of wherever it's actually a light-DOM child, so
|
|
||||||
// re-parenting into this element's own shadow root would make the
|
|
||||||
// caller's classes go dark.
|
|
||||||
//
|
|
||||||
// `openInstances` tracks every open instance app-wide (a deliberate
|
|
||||||
// widening from the old per-agent-menu-only coordination) so
|
|
||||||
// `closeAll()` can close each via its own `.close()`. `closeAllMenus()`
|
|
||||||
// force-closes everything before tearing down DOM one might anchor off
|
|
||||||
// of (e.g. swarm.js before replacing the container tree).
|
|
||||||
//
|
|
||||||
// The document-level listeners below, registered once: shadow-DOM
|
|
||||||
// retargeting means `e.target` for a click inside a shadow tree gets
|
|
||||||
// retargeted past that instance's host, so `e.composedPath()` (includes
|
|
||||||
// slotted content) tests "did this land inside any open menu" — same
|
|
||||||
// reasoning as `<hive-dialog>`'s backdrop-click check.
|
|
||||||
|
|
||||||
import { el } from '../dom.js';
|
|
||||||
import { attachShadowCss } from '../shadow-css.js';
|
|
||||||
import hiveMenuCss from './hive-menu.css';
|
|
||||||
|
|
||||||
const openInstances = new Set();
|
|
||||||
|
|
||||||
function closeAll() {
|
|
||||||
for (const inst of [...openInstances]) inst.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported for any caller that needs to force-close every open menu —
|
|
||||||
// see module header.
|
|
||||||
export function closeAllMenus() {
|
|
||||||
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 a caller's own
|
|
||||||
// selection-clear Escape handler (e.g. swarm.js's) doesn't also fire
|
|
||||||
// while a menu is open.
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && openInstances.size) {
|
|
||||||
closeAll();
|
|
||||||
e.stopImmediatePropagation();
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
class HiveMenu extends HTMLElement {
|
|
||||||
connectedCallback() {
|
|
||||||
const { trigger, content } = this._opts || {};
|
|
||||||
const root = attachShadowCss(this, hiveMenuCss);
|
|
||||||
|
|
||||||
// Project the caller's opaque nodes via named slots — see module
|
|
||||||
// header for why this has to be slotting, not a shadow-root append.
|
|
||||||
trigger.slot = 'trigger';
|
|
||||||
content.slot = 'content';
|
|
||||||
this.append(trigger, content);
|
|
||||||
|
|
||||||
const dropdown = el('div', { class: 'menu-dropdown', hidden: true });
|
|
||||||
dropdown.append(el('slot', { name: 'content' }));
|
|
||||||
root.append(el('slot', { name: 'trigger' }), dropdown);
|
|
||||||
|
|
||||||
this._trigger = trigger;
|
|
||||||
this._dropdown = dropdown;
|
|
||||||
|
|
||||||
trigger.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const wasOpen = openInstances.has(this);
|
|
||||||
closeAll();
|
|
||||||
if (!wasOpen) this.open();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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._trigger.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._trigger.setAttribute('aria-expanded', 'false');
|
|
||||||
this.style.removeProperty('--menu-btn-opacity');
|
|
||||||
openInstances.delete(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customElements.define('hive-menu', HiveMenu);
|
|
||||||
Loading…
Reference in a new issue