diff --git a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js new file mode 100644 index 00000000..54fe3b60 --- /dev/null +++ b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js @@ -0,0 +1,181 @@ +// 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. +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); diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 916518a6..bb2a59ca 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -279,53 +279,41 @@ body.dashboard-shell { } /* ── per-agent three-dot context menu ───────────────────────────────────── - Positioned after .card-body in the flex row. */ -.agent-menu { + Positioned after .card-body in the flex row. `` + (agent-menu/hive-agent-menu.js) is a light-DOM element with no shadow + root of its own — the generic dropdown mechanics (shadow attach, + open/close, positioning) live in the shared `` 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 ``'s own `:host` instead, since that's the element whose + shadow tree the dropdown is actually positioned within. */ +hive-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-btn carries no styling of its own anymore — the trigger is + a top-level `slot="trigger"` node inside 's shadow tree, so + its base icon-button chrome now lives in hive-menu.css's + `::slotted([slot='trigger'])` rules instead (reachable and genuinely + generic — any trigger gets the same treatment). The class + itself stays, just as an identification hook (aria-label/title + already carry the real semantics). */ +/* Box chrome (background/border/radius/shadow/min-width/white-space) now + lives on 's own `.menu-dropdown` wrapper (shadow-DOM-owned + markup, no slotting constraint) — this is just the `
    `'s own + list-reset + item padding, which only the caller (owning the actual + list markup) can set. */ .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; @@ -352,6 +340,13 @@ body.dashboard-shell { margin: 0.3em 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 ) applies + — inherits down through `hive-agent-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. */ .container-row.pending .actions { opacity: 0.4; pointer-events: none; } .container-row.pending-running { diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index eb906ec5..fbfdb266 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -11,6 +11,8 @@ import { themedConfirm, themedToast } from '@hive/shared/modal.js'; import { containersState, questionsState, } from './state.js'; +import { closeAllMenus } from '@hive/shared/hive-menu.js'; +import './agent-menu/hive-agent-menu.js'; // registers — side-effect import // Context-window badge thresholds. Preferred source is each container's // `context_window_tokens` from /api/state (the real window for the model @@ -45,8 +47,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 +186,19 @@ 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 + agent-specific interaction lives in the +// custom element (./agent-menu/hive-agent-menu.js, imported above for its +// customElements.define side effect); the generic dropdown mechanics +// (open/close, positioning, singleton coordination — closed via +// `closeAllMenus` below) live in the shared `` component it +// composes internally (@hive/shared/hive-menu.js). This is a thin +// wrapper matching '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 ────────────────────────────────────────────────────────── @@ -403,7 +230,7 @@ function derivePortConflicts(containers) { function buildAgentTree(containers) { // Close any open context menu before replacing the DOM tree — the // previous dropdown element would otherwise be a stale reference. - closeAllAgentMenus(); + closeAllMenus(); const byName = new Map(); for (const c of containers) byName.set(c.name, c); const children = new Map(); // parent_name -> [child_name, ...] diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 333bcd63..3bdbeeb0 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -17,7 +17,9 @@ "./chrome.css": "./src/chrome.css", "./forms.js": "./src/forms.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": [ "src/" diff --git a/frontend/packages/shared/src/hive-menu/hive-menu.css b/frontend/packages/shared/src/hive-menu/hive-menu.css new file mode 100644 index 00000000..e29d3a0a --- /dev/null +++ b/frontend/packages/shared/src/hive-menu/hive-menu.css @@ -0,0 +1,58 @@ +/* hive-menu.css — scoped stylesheet for the generic + shadow-DOM custom element (hive-menu.js). Loaded as raw text at build + time (esbuild's `text` loader) and appended as a