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);

View file

@ -279,17 +279,90 @@ body.dashboard-shell {
}
/* per-agent three-dot context menu
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()). */
Positioned after .card-body in the flex row. `<hive-agent-menu>`
(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 `<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;
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);
}
/* Positioning (`position`/`top`/`right`/`z-index`) is generic and lives
on `<hive-menu>`'s own `.menu-dropdown` wrapper instead this is just
the item list's visual chrome. */
.agent-menu-dropdown {
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;
}
/* 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;
}

View file

@ -11,7 +11,8 @@ import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import {
containersState, questionsState,
} from './state.js';
import { closeAllAgentMenus } from './agent-menu/hive-agent-menu.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_tokens` from /api/state (the real window for the model
@ -185,12 +186,15 @@ 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 + 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.
// Rendering + agent-specific interaction lives in the <hive-agent-menu>
// 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 `<hive-menu>` component it
// composes internally (@hive/shared/hive-menu.js). 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 menu = document.createElement('hive-agent-menu');
menu._opts = { c, forgeBase };
@ -226,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, ...]

View file

@ -18,7 +18,8 @@
"./forms.js": "./src/forms.js",
"./dom.js": "./src/dom.js",
"./modal.js": "./src/modal.js",
"./shadow-css.js": "./src/shadow-css.js"
"./shadow-css.js": "./src/shadow-css.js",
"./hive-menu.js": "./src/hive-menu/hive-menu.js"
},
"files": [
"src/"

View file

@ -0,0 +1,32 @@
/* 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.
`:host` carries only the one truly generic requirement: it's the
containing block for the shadow tree's absolutely-positioned 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. Any *layout* role the host plays in a particular
caller's own flex row (e.g. <hive-agent-menu>'s placement in
`.container-row`) is caller-specific and lives in the caller's own
stylesheet instead, not here.
`.menu-dropdown` is the generic positioning box wrapping the caller's
opaque `content` node (projected in via `<slot name="content">` see
hive-menu.js). Only position/z-index/visibility live here; the
button/item-row *visual* styling (colors, fonts, hover states) is
presentational content the caller's own trigger/content nodes carry,
so it lives in the caller's own stylesheet `<hive-menu>` never sees
those class names, only the opaque nodes handed to it. */
:host {
position: relative;
}
.menu-dropdown {
position: absolute;
right: 0;
top: calc(100% + 2px);
z-index: 50;
}

View file

@ -0,0 +1,113 @@
// 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);