From 6f57b1f57c1af494ed9233286e686121c793b805 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 7 Sep 2026 22:31:44 +0200 Subject: [PATCH] swarm-ui: one badge+dropdown for wanted state, not multiple buttons mara, hyperhive#4079: "agent wanted state is multiple buttons insteaf of a badge with dropdown ... same pattern as agent term badges with dropdowns". The wanted column used to be a toggle badge plus a separate quiet destroy badge, stacking under each other in the narrow column. Replaced both with one WantedMenu badge that opens a Dropdown with the three explicit states (up/offline/destroy) -- the exact badge-triggers-a-dropdown shape the per-agent terminals StatusChips already uses (and swarm-uis own ComponentsPage already demos with sample data), built from the same shared Badge/Dropdown components. "up" still declares straight away with no confirmation; "offline" and "destroy" still go through the existing ConfirmDialog modals unchanged -- only the trigger moved, the confirm behavior for the two directions that already had one is untouched. Explicit dropdown options also fix a real bug the old toggle had: mara also asked "when no state is declared, i want to set it to online" -- the old toggle inferred a target as the opposite of snapshot.running for an undeclared row, so a click on an undeclared-but-running agent silently declared it offline rather than making its actual state explicit. The dropdown just lets "up" be picked directly regardless of any inference, which is what she is asking for -- flagging this reading explicitly in case an actual one-time migration (auto-declaring every currently-undeclared agent up) was intended instead, which this does not do. Added a shared .ui-dropdown-anchor utility class to Dropdown.css -- this is the third near-identical "position: relative wrapper for a badge that opens a Dropdown" (after agents own StatusChips.css and swarm-uis ComponentsPage.css), so a new caller should not reinvent a fourth copy. Left the two existing ones alone rather than migrating them as a drive-by. Verified with a local esbuild build + a throwaway mock /api/agents/status server, screenshotted headlessly: the wanted column now shows exactly one badge per row instead of stacked badges. --- .../packages/shared/src/dropdown/Dropdown.css | 13 ++ .../swarm-ui/src/pages/AgentsPage.tsx | 184 ++++++++++-------- 2 files changed, 113 insertions(+), 84 deletions(-) diff --git a/frontend/packages/shared/src/dropdown/Dropdown.css b/frontend/packages/shared/src/dropdown/Dropdown.css index afc2577e..0310cf95 100644 --- a/frontend/packages/shared/src/dropdown/Dropdown.css +++ b/frontend/packages/shared/src/dropdown/Dropdown.css @@ -3,6 +3,19 @@ comment) so it hangs directly under the badge that opened it. `--bg-elev` is the same elevated-surface slot the badge uses when open (../badge/Badge.css), so the pair reads as one continuous panel. */ +/* Reusable wrapper for the "badge that opens a `Dropdown` right + underneath itself" shape: `position: relative` is what lets + `.ui-dropdown`'s `position: absolute` above anchor to this box + instead of the page. A couple of callers predate this and declare + their own page-scoped copy of the same two rules (agent's + `StatusChips.css` `.status-chip-anchor`, swarm-ui's + `ComponentsPage.css` `.components-badge-anchor`) — left alone here + rather than migrated as a drive-by, but a *new* caller should reach + for this instead of reinventing a third one. */ +.ui-dropdown-anchor { + position: relative; + display: inline-block; +} .ui-dropdown { position: absolute; top: calc(100% + 0.25em); diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 6da04c0a..4010af2b 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -25,13 +25,13 @@ // itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than // its own route. // -// The "wanted" column is the start/stop control (`toggleWanted`) plus a -// separate destroy control (`destroyAgent`) — both confirm via the -// shared `ui/confirm-dialog`. -import { useState } from "preact/hooks"; +// The "wanted" column is one `WantedMenu` badge+dropdown per row — see +// that component's own comment above `AgentsPage` for why. +import { useRef, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; +import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; import { Button } from "../ui/button/Button.js"; import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; import { Dialog } from "../ui/dialog/Dialog.js"; @@ -103,6 +103,80 @@ const FRESHNESS: Record = { // solve staleness rather than require an opt-in every visit. const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; +// The "wanted" column's control: one badge showing the current +// declaration, opening a `Dropdown` with the three explicit states — +// replaces the old toggle-badge-plus-separate-destroy-badge pair. +// Explicit options also fix a real bug the toggle had: for an +// undeclared row, the toggle inferred a target as "the opposite of +// `snapshot.running`", so a click on an undeclared-but-running agent +// silently declared it *offline* rather than making its real state +// explicit — a dropdown just lets "up" be picked directly regardless +// of what's inferred, no flip-to-the-opposite-of-a-guess involved +// (mara: "when no state is declared, i want to set it to online"). +// Presentational only, same split as `StatusChips`'s `Picker`/ +// `StatusMenu`: the caller owns what each selection actually does. +function WantedMenu({ + row, + pending, + onSelectUp, + onSelectOffline, + onDestroy, +}: { + row: AgentRow; + pending: boolean; + onSelectUp: (row: AgentRow) => void; + onSelectOffline: (row: AgentRow) => void; + onDestroy: (row: AgentRow) => void; +}) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const destroyed = row.wanted === "destroyed"; + const tone: BadgeTone = + row.wanted === "up" ? "positive" : destroyed ? "negative" : "neutral"; + const options: DropdownOption[] = [ + { value: "up", label: "up" }, + { value: "offline", label: "offline" }, + { value: "destroy", label: "destroy", danger: true }, + ]; + + return ( +
+ setOpen((o) => !o) : undefined} + expanded={open} + disabled={pending || !row.hive || destroyed} + title={ + destroyed + ? `${row.name} is destroyed — redeploy via "+ agent" to bring it back` + : row.hive + ? `declare a new state for ${row.name}` + : "no hive on record for this agent — nothing to declare against" + } + /> + { + setOpen(false); + if (value === "up") onSelectUp(row); + else if (value === "offline") onSelectOffline(row); + else onDestroy(row); + }} + onClose={() => setOpen(false)} + anchorRef={anchorRef} + /> +
+ ); +} + export function AgentsPage() { const [rows, setRows] = useState(null); const [error, setError] = useState(null); @@ -120,10 +194,10 @@ export function AgentsPage() { // The row pending destroy confirmation, `null` when the dialog is // closed — not a boolean, so the dialog can name the agent without a // second piece of state to keep in sync with it. `stopTarget` mirrors - // it for the "declare offline" direction of the same toggle — two - // separate pieces of state (not one "pending confirm" union) since - // both `ConfirmDialog`s can't be open at once anyway and keeping them - // apart means each one's JSX below reads standalone. + // it for `WantedMenu`'s "offline" option — two separate pieces of + // state (not one "pending confirm" union) since both `ConfirmDialog`s + // can't be open at once anyway and keeping them apart means each + // one's JSX below reads standalone. const [destroyTarget, setDestroyTarget] = useState(null); const [stopTarget, setStopTarget] = useState(null); @@ -144,11 +218,12 @@ export function AgentsPage() { refresh().catch((e: unknown) => setError({ detail: String(e) })); }); - // Shared by `confirmStop` and `destroyAgent` — both are "PUT a new - // `wanted` declaration, track per-agent pending/error state, patch the - // response back into `rows`" with nothing else distinguishing them. - // Which `ConfirmDialog` fires it and the target state stay in each - // caller, since those are the parts that actually differ. + // The one PUT every `WantedMenu` selection ends at, whether or not it + // went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent` + // for "offline"/"destroy"; called directly for "up", which needs no + // confirmation): "PUT a new `wanted` declaration, track per-agent + // pending/error state, patch the response back into `rows`" is the + // same shape regardless of target state or how the caller got here. async function declareState(row: AgentRow, target: string) { if (!row.hive) return; setPendingAgents((prev) => new Set(prev).add(row.name)); @@ -201,27 +276,6 @@ export function AgentsPage() { } } - // Declares the opposite of `row`'s current state. A row with no - // declaration yet (`wanted === null`) has nothing to flip, so the - // target is read off the agent's own last-reported `running` instead - // — the button's first click always means "make the declaration match - // reality, then flip it", which is the only reading that makes sense - // without a declaration to toggle. The "up" direction needs no - // confirmation and declares straight away; "offline" opens the - // `stopTarget` `ConfirmDialog` instead of declaring directly — see - // `confirmStop`. - async function toggleWanted(row: AgentRow) { - if (!row.hive) return; - const impliedCurrent = - row.wanted ?? (row.snapshot?.running ? "up" : "offline"); - const target = impliedCurrent === "up" ? "offline" : "up"; - if (target === "offline") { - setStopTarget(row); - return; - } - await declareState(row, target); - } - // Fires from the `stopTarget` confirm `Dialog`. Used to be a native // `window.confirm` (stop is reversible, a later start un-does it, so a // lighter-weight prompt than destroy's felt proportionate) — mara, @@ -235,9 +289,9 @@ export function AgentsPage() { } // Fires from the `destroyTarget` confirm `Dialog`, never directly off - // a row click — see `destroyTarget`/the "destroy" badge in the - // `wanted` column. Unlike `toggleWanted`, there's no "current state" - // to read: destroy is a one-way declaration, not a flip. + // a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option. + // Unlike the "up"/"offline" directions, there's no "current state" to + // read: destroy is a one-way declaration, not a flip. async function destroyAgent(row: AgentRow) { setDestroyTarget(null); await declareState(row, "destroyed"); @@ -308,60 +362,22 @@ export function AgentsPage() { { key: "wanted", header: "wanted", - // The start/stop control: clicking the current-declared-state - // badge toggles it — `Badge`'s own "chip plus, optionally, the - // control" shape (see its header comment, which names pause/resume - // as the exact motivating case), not a separate status chip next - // to a separate button. Destroy is a second, `quiet`-variant badge - // next to it rather than a third toggle state: folding it into the - // same click target would make one wrong click irreversible, and - // `wanted === "destroyed"` already reads fine as this badge's own - // display value without a dedicated affordance to view it. + // One `WantedMenu` (badge + dropdown, defined above) per row — + // real per-badge logic lives there, this column just wires its + // callbacks to the page's own state/handlers. sortBy: (a) => a.wanted ?? "", filterValue: (a) => a.wanted ?? "no declaration", render: (a) => { - const pending = pendingAgents.has(a.name); - const impliedCurrent = - a.wanted ?? (a.snapshot?.running ? "up" : "offline"); - const actionLabel = impliedCurrent === "up" ? "stop" : "start"; - const tone: BadgeTone = a.wanted === "up" ? "positive" : "neutral"; const err = actionErrors.get(a.name); - // Already destroyed, or nothing to destroy against — same guard - // shape as the wanted toggle's own `!a.hive` check. - const destroyable = a.hive && a.wanted !== "destroyed"; - const destroyed = a.wanted === "destroyed"; return ( <> - void toggleWanted(a) : undefined - } - disabled={pending || !a.hive || destroyed} - title={ - destroyed - ? `${a.name} is destroyed — redeploy via "+ agent" to bring it back` - : a.hive - ? `click to ${actionLabel} ${a.name}` - : "no hive on record for this agent — nothing to declare against" - } + void declareState(row, "up")} + onSelectOffline={setStopTarget} + onDestroy={setDestroyTarget} /> - {destroyable ? ( - setDestroyTarget(a)} - disabled={pending} - title={`destroy ${a.name} — tears the container down, irreversible`} - /> - ) : null} {err ? (