// 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. `flags` is an object // of boolean query params to set truthy (e.g. `{ graceful: true }` or // `{ paused: true }`) — every menu action so far needs at most one, but // this stays a plain object rather than a single named arg so a future // action needing two doesn't have to touch this signature again. async function agentMenuPost(actionPath, name, body, flags) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(flags || {})) { if (v) params.set(k, "true"); } const qs = params.toString(); const url = actionPath + encodeURIComponent(name) + (qs ? "?" + qs : ""); 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() { // Same reconnect-without-detach hazard as (see its // connectedCallback comment) — swarm.js's row cache can move an // already-built
  • subtree without a real detach. Without this // guard a reconnect would append a second on top of the // first, doubling the dropdown. if (this._menu) return; 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 flags = {}; if (opts.confirm) { const checkboxes = []; if (opts.graceful) { checkboxes.push({ name: "graceful", label: opts.gracefulLabel || "stop gracefully — let the agent finish its turn and flush state before the container stops", }); } if (opts.paused) { checkboxes.push({ name: "paused", label: opts.pausedLabel || "start paused — come up without driving turns until resumed", }); } const r = await themedConfirm({ message: opts.confirm, danger: true, confirmLabel: opts.confirmLabel || "confirm", checkboxes, }); if (!r) return; flags = { graceful: !!r.graceful, paused: !!r.paused }; } await agentMenuPost(opts.action, c.name, opts.body || null, flags); }); 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}?`, paused: true, pausedLabel: "start paused — come up without driving turns until resumed", }), ); } // 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);