Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx
atlas 716290e4d2 swarm-controller: state route refuses an agent the swarm has not declared on that hive
A placing state is accepted only for an agent declared or queued on the hive; anything else gets 404 telling the caller to create it at swarm level. swarm-ui's wanted badge no longer offers a first declaration for an undeclared row.
2026-10-25 15:58:25 +01:00

121 lines
4.7 KiB
TypeScript

// <WantedMenu> — the "wanted" state control: one badge showing the
// current declaration, opening a `Dropdown` with the four explicit
// states. An agent with no declaration has none to change: its badge is
// disabled and points at creating it at swarm level, because the state
// route refuses an agent the swarm never declared on that hive.
// Presentational only, same split as `StatusChips`'s
// `StatusMenu`: the caller owns what each selection actually does.
//
// "paused" has no separate resume option here — selecting "up" from a
// paused row is the resume, same PUT either way (`AgentsPage`'s
// `declareState`, `hive-c0re`'s `decide_pause` backend counterpart,
// treats a declared `Up` with the marker still set as "clear it"). A
// dedicated "resume" entry would just be a second spelling of the
// option already above it.
import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import type { AgentRow } from "../AgentTypes.js";
export function WantedMenu({
row,
pending,
onSelectUp,
onSelectOffline,
onSelectPaused,
onDestroy,
showDestroy = true,
portal = false,
}: {
row: AgentRow;
pending: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onDestroy?: (row: AgentRow) => void;
/**
* The card view (see `AgentCard`) passes `false` here — its
* quick-access menu keeps to the three everyday states, so "destroy"
* can't be reached by an extra click off a state that's already open.
* `AgentsPage`'s detail-panel `WantedMenu` keeps the default (`true`)
* — mara: "destroy is already available via wanted state", i.e. the
* full menu there, not a separate button.
*/
showDestroy?: boolean;
/**
* Forwarded to `Dropdown`'s own `portal` (see its file-top comment) —
* only the table's "wanted" column needs it, to escape
* `.ui-table-scroll`'s clip on the table's last row (the bug `Dropdown`'s
* own `portal` prop exists to fix in the first place).
* Neither the card list nor the detail panel has a clipping ancestor,
* and inside the detail panel specifically `portal` is actively wrong:
* a `position: fixed` element appended to `document.body` renders
* *behind* an open native `<dialog>` (the dialog is promoted to the
* browser's top layer, which composites above ordinary body content
* regardless of z-index) — found while screenshotting this exact
* dropdown open inside the detail panel, only the portion extending
* past the dialog's own edge was visible.
*/
portal?: boolean;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const destroyed = row.wanted === "destroyed";
const undeclared = !row.wanted;
const canDeclare = !!row.hive && !destroyed && !undeclared;
const tone: BadgeTone =
row.wanted === "up"
? "positive"
: row.wanted === "paused"
? "warning"
: destroyed
? "negative"
: "neutral";
const options: DropdownOption[] = [
{ value: "up", label: "up" },
{ value: "paused", label: "paused" },
{ value: "offline", label: "offline" },
...(showDestroy
? [{ value: "destroy", label: "destroy", danger: true }]
: []),
];
return (
<div class="ui-dropdown-anchor" ref={anchorRef}>
<Badge
tone={tone}
// `destroyed` and an undeclared row block opening the menu at
// all — a UX-only guard, the server enforces both (see
// `declareState`'s caller).
onClick={canDeclare ? () => setOpen((o) => !o) : undefined}
expanded={open}
disabled={pending || !canDeclare}
title={
destroyed
? `${row.name} is destroyed — redeploy via "+ agent" to bring it back`
: !row.hive
? "no hive on record for this agent — nothing to declare against"
: undeclared
? `${row.name} is not declared at swarm level — create it there`
: `declare a new state for ${row.name}`
}
/>
<Dropdown
open={open}
portal={portal}
options={options}
activeValue={row.wanted ?? undefined}
label={`declare ${row.name}`}
onSelect={(value) => {
setOpen(false);
if (value === "up") onSelectUp(row);
else if (value === "offline") onSelectOffline(row);
else if (value === "paused") onSelectPaused(row);
else onDestroy?.(row);
}}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
/>
</div>
);
}