diff --git a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js deleted file mode 100644 index 54fe3b60..00000000 --- a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js +++ /dev/null @@ -1,181 +0,0 @@ -// 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 bb2a59ca..916518a6 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -279,41 +279,53 @@ body.dashboard-shell { } /* ── per-agent three-dot context 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 { + 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 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-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; @@ -340,13 +352,6 @@ hive-agent-menu { 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 fbfdb266..eb906ec5 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -11,8 +11,6 @@ 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 @@ -47,6 +45,8 @@ 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,19 +186,192 @@ 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. -// 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 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' }); + } +} + function buildAgentMenu(c, forgeBase) { - const menu = document.createElement('hive-agent-menu'); - menu._opts = { c, forgeBase }; - return menu; + 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; } // ─── port conflicts ────────────────────────────────────────────────────────── @@ -230,7 +403,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. - closeAllMenus(); + closeAllAgentMenus(); 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 3bdbeeb0..333bcd63 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -17,9 +17,7 @@ "./chrome.css": "./src/chrome.css", "./forms.js": "./src/forms.js", "./dom.js": "./src/dom.js", - "./modal.js": "./src/modal.js", - "./shadow-css.js": "./src/shadow-css.js", - "./hive-menu.js": "./src/hive-menu/hive-menu.js" + "./modal.js": "./src/modal.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 deleted file mode 100644 index e29d3a0a..00000000 --- a/frontend/packages/shared/src/hive-menu/hive-menu.css +++ /dev/null @@ -1,58 +0,0 @@ -/* 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