frontend: split hive-agent-menu's generic dropdown mechanics into a shared hive-menu component

<hive-agent-menu> bundled two concerns: the agent-specific trigger/item
list, and generic "click a trigger, get a positioned dropdown" mechanics
(shadow attach, open/close, singleton close-on-open coordination,
outside-click/Escape handling). Pulled the latter out into a new
@hive/shared/hive-menu.js (<hive-menu>), following the established
per-component-directory + ._opts-before-append shadow-DOM pattern
(<hive-dialog>). <hive-agent-menu> now just builds the "⋮" trigger and
the action list and hands them to an internal <hive-menu> instance.

<hive-menu> takes ownership of every <hive-menu> instance in the app for
singleton coordination (closeAllMenus, renamed from closeAllAgentMenus)
— a deliberate widening from the old per-agent-menu-only tracking, since
the mechanism was never agent-specific to begin with.

The one subtlety worth spelling out: <hive-menu> projects the caller's
opaque trigger/content nodes via named <slot>s rather than moving them
into its own shadow root. That's load-bearing, not cosmetic — if it
re-parented them into its own shadow tree instead, <hive-agent-menu>'s
own classes (.agent-menu-btn, .agent-menu-item, ...) would stop applying,
since a <style> only styles elements within the same shadow tree/document
it's part of, and only slotting (not re-parenting) keeps the caller's
nodes in the caller's own tree for styling purposes. That in turn made
<hive-agent-menu>'s own shadow root redundant once it wasn't the thing
positioning or owning open/close state anymore, so it's dropped in favor
of a plain light-DOM element styled by dashboard.css (already the one
page it renders on) — hive-agent-menu.css is gone, its rules folded into
dashboard.css's per-agent-menu section, minus the positioning rules that
moved into hive-menu.css as the new generic `.menu-dropdown` wrapper.

Verified with a standalone esbuild bundle + a cached nix chromium driven
over raw CDP (no puppeteer/playwright/python3 available): hover-reveal
opacity, dropdown open/close/positioning, outside-click/Escape dismissal,
and cross-instance singleton coordination all behave identically to
before the split.
This commit is contained in:
iris 2026-07-31 22:53:36 +02:00 committed by mara
commit 395c9a6df2
7 changed files with 272 additions and 203 deletions

View file

@ -1,91 +0,0 @@
/* 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;
}

View file

@ -1,66 +1,30 @@
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
// shadow-DOM custom element. One instance per agent card, appended after
// 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.
// buildContainerLi via buildAgentMenu).
//
// 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.
// 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 { 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);
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) {
@ -86,7 +50,6 @@ async function agentMenuPost(actionPath, name, body, graceful) {
class HiveAgentMenu extends HTMLElement {
connectedCallback() {
const { c, forgeBase } = this._opts || {};
const root = attachShadowCss(this, agentMenuCss);
const btn = el('button', {
type: 'button',
@ -96,9 +59,9 @@ class HiveAgentMenu extends HTMLElement {
'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 dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' });
const close = () => this._menu.close();
const menuItem = (label, opts) => {
const li = el('li', { role: 'presentation' });
@ -108,7 +71,7 @@ class HiveAgentMenu extends HTMLElement {
role: 'menuitem',
}, label);
item.addEventListener('click', async () => {
this.close();
close();
let graceful = false;
if (opts.confirm) {
const r = await themedConfirm({
@ -139,7 +102,7 @@ class HiveAgentMenu extends HTMLElement {
role: 'menuitem',
title: title || '',
}, label);
a.addEventListener('click', () => this.close());
a.addEventListener('click', close);
li.append(a);
return li;
};
@ -210,35 +173,9 @@ class HiveAgentMenu extends HTMLElement {
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);
this._menu = document.createElement('hive-menu');
this._menu._opts = { trigger: btn, content: dropdown };
this.append(this._menu);
}
}
customElements.define('hive-agent-menu', HiveAgentMenu);