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 ? (