hyperhive/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

259 lines
8.8 KiB
JavaScript

// hive-agent-menu.js — <hive-agent-menu>, 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 `<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 "@hive/shared/hive-menu.js"; // registers <hive-menu> — 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 <hive-menu> (see its
// connectedCallback comment) — swarm.js's row cache can move an
// already-built <li> subtree without a real detach. Without this
// guard a reconnect would append a second <hive-menu> 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);