diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 680f266b..b24cc6a9 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -25,9 +25,9 @@ // itself (`CreateAgentForm`) mounts inside a `Dialog` here rather than // its own route. // -// The "wanted" column is the start/stop control — see its own `render` -// and `toggleWanted` below for how. Destroy is a separate control next -// to it — see `destroyTarget`/`destroyAgent`. +// 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"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; @@ -119,8 +119,13 @@ export function AgentsPage() { >(new Map()); // 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. + // 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. const [destroyTarget, setDestroyTarget] = useState(null); + const [stopTarget, setStopTarget] = useState(null); async function refresh() { const res = await fetch("/api/agents/status"); @@ -139,11 +144,11 @@ export function AgentsPage() { refresh().catch((e: unknown) => setError({ detail: String(e) })); }); - // Shared by `toggleWanted` and `destroyAgent` — both are "PUT a new + // 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. - // Confirmation (native vs. `Dialog`) and target-state selection stay - // in each caller, since those are the parts that actually differ. + // Which `ConfirmDialog` fires it and the target state stay in each + // caller, since those are the parts that actually differ. async function declareState(row: AgentRow, target: string) { if (!row.hive) return; setPendingAgents((prev) => new Set(prev).add(row.name)); @@ -201,31 +206,38 @@ export function AgentsPage() { // 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. + // 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" && - // Native confirm, not a `Dialog` — stop is reversible (a later - // start un-does it), so the lighter-weight native prompt is - // proportionate here. Destroy is the irreversible direction and - // gets the real `Dialog` confirm instead — see `destroyAgent`. - !window.confirm( - `Declare ${row.name} offline? The hive brings its container down on its next reconcile sweep.`, - ) - ) { + if (target === "offline") { + setStopTarget(row); return; } await declareState(row, target); } - // Fires from the 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. + // 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, + // reviewing the destroy confirm: use the shared component everywhere + // we already confirm before acting, not just for destroy. Once + // `ConfirmDialog` existed as a one-line-per-caller component, the + // "native is lighter" argument no longer bought consistency anything. + async function confirmStop(row: AgentRow) { + setStopTarget(null); + await declareState(row, "offline"); + } + + // 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. async function destroyAgent(row: AgentRow) { setDestroyTarget(null); await declareState(row, "destroyed"); @@ -415,6 +427,20 @@ export function AgentsPage() { > + setStopTarget(null)} + onConfirm={() => stopTarget && void confirmStop(stopTarget)} + confirmLabel="stop" + > + {stopTarget ? ( +

+ Declare {stopTarget.name} offline? The hive brings + its container down on its next reconcile sweep. +

+ ) : null} +