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.
259 lines
8.8 KiB
JavaScript
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);
|