Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
29c94ea9e6 swarm-ui: disable the wanted-toggle badge on a destroyed row
argus, review: the primary wanted-toggle badge still had no
wanted === "destroyed" guard, so it stayed clickable on a destroyed
row and would PUT {state: "up"} on click -- directly contradicting
the destroy confirm dialogs own "not reversible from here" copy.
Missed this in the previous fix-up (which only addressed the
ConfirmDialog-for-stop ask). Added the guard to both the click handler
and disabled, plus a title explaining why on a destroyed row.
2026-09-07 19:11:50 +02:00
iris
2874badd4c swarm-ui: use ConfirmDialog for the stop confirmation too
mara: "use the new component where we already have confirm dialogs."
The stop/offline direction of the wanted toggle used a native
window.confirm -- the reasoning at the time was that a hand-rolled
Dialog felt heavy for a reversible action, with the real Dialog confirm
reserved for the irreversible destroy direction. Now that
ConfirmDialog is a one-line-per-caller shared component, that argument
no longer buys consistency anything, so both directions confirm the
same way.
2026-09-07 19:07:21 +02:00
iris
82a4324b17 swarm-ui: extract ConfirmDialog, use it for the destroy confirm
mara, reviewing the swarm-ui destroy-trigger PR: "why the extra
styling? shouldnt there be a component that does this already?" There
wasnt one -- Dialog is deliberately content-agnostic (see its own
file-top comment), so the destroy confirm had grown its own
page-scoped .agents-destroy-confirm/-actions CSS for what is really a
generic "message + cancel/confirm button row" shape.

Extracted ui/confirm-dialog/ConfirmDialog.tsx: wraps Dialog, owns the
button row, leaves the message body to the caller via children.
AgentsPage now uses it instead of a bare Dialog + bespoke CSS; deleted
the now-unused AgentsPage.css.
2026-09-07 19:02:52 +02:00
iris
becc025f13 swarm-ui: add a destroy trigger to the agents page
Closes #4067. Backend half (Wanted::Destroyed + reconcile) shipped in
#4065 with no swarm-controller API changes needed -- SetAgentStateRequest
already accepted {"state": "destroyed"}, it just had nothing in swarm-ui
sending it.

Destroy is a separate `quiet`-variant badge next to the existing
start/stop toggle (#3988's "wanted" column), not a third state folded
into that same click target -- one wrong click on a shared toggle
would be irreversible, where a dedicated badge only fires from its own
confirm dialog. That confirm is a real `Dialog`, not the native
`window.confirm` the reversible stop direction uses -- the wanted
column's own comment called this out as the case that would justify
one when it was first written.

Shared the PUT-declare/pending/error/patch-rows logic between the
existing toggleWanted and the new destroyAgent (declareState) rather
than duplicating it -- confirmation and target-state selection are the
only parts that differ between a toggle and a one-way declaration.

Verified: `tsc --noEmit` clean, `nix fmt` reports the expected
formatting-only diff, wire shapes (state string "destroyed",
AgentDeclaration response) checked against swarm-queue-client's
AgentState::as_str and swarm-controller's actual handler rather than
assumed from the issue description.
2026-09-07 18:53:55 +02:00
3 changed files with 196 additions and 33 deletions

View file

@ -25,13 +25,15 @@
// 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.
// 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";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Button } from "../ui/button/Button.js";
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../ui/dialog/Dialog.js";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
@ -56,8 +58,9 @@ interface AgentStatusSnapshot {
running: boolean;
}
// `"up"` / `"offline"`, wire-spelled by `swarm_queue_client::wanted::AgentState::as_str`
// — kept as a bare `string | null` rather than a union, same reason
// `"up"` / `"offline"` / `"destroyed"`, wire-spelled by
// `swarm_queue_client::wanted::AgentState::as_str` — kept as a bare
// `string | null` rather than a union, same reason
// `config_pr`'s shape isn't re-derived here: this page renders whatever
// the wire sends, it doesn't validate the enum client-side.
type Wanted = string | null;
@ -114,6 +117,15 @@ export function AgentsPage() {
const [actionErrors, setActionErrors] = useState<
ReadonlyMap<string, ProblemDetails>
>(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. `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<AgentRow | null>(null);
const [stopTarget, setStopTarget] = useState<AgentRow | null>(null);
async function refresh() {
const res = await fetch("/api/agents/status");
@ -132,30 +144,13 @@ export function AgentsPage() {
refresh().catch((e: unknown) => setError({ detail: String(e) }));
});
// 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.
async function toggleWanted(row: AgentRow) {
// 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.
async function declareState(row: AgentRow, target: string) {
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` — no confirm-dialog component
// exists in swarm-ui yet, and stop is the one disruptive direction
// here (start isn't gated). A swarm-level agent destroy, unlike
// this reversible declare, is the irreversible case that will
// justify a real one.
!window.confirm(
`Declare ${row.name} offline? The hive brings its container down on its next reconcile sweep.`,
)
) {
return;
}
setPendingAgents((prev) => new Set(prev).add(row.name));
setActionErrors((prev) => {
const next = new Map(prev);
@ -206,6 +201,48 @@ 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,
// 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");
}
const columns: TableColumn<AgentRow>[] = [
{
key: "name",
@ -275,7 +312,11 @@ export function AgentsPage() {
// 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.
// 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.
sortBy: (a) => a.wanted ?? "",
filterValue: (a) => a.wanted ?? "no declaration",
render: (a) => {
@ -285,19 +326,42 @@ export function AgentsPage() {
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 (
<>
<Badge
tone={tone}
value={pending ? "…" : (a.wanted ?? "no declaration")}
onClick={a.hive ? () => void toggleWanted(a) : undefined}
disabled={pending || !a.hive}
// `destroyed` blocks the click too, not just the label —
// argus caught this in review: without it, a click here
// still computed `target = "up"` and PUT a live
// declaration on a destroyed row, contradicting the
// destroy confirm's own "not reversible from here" copy.
onClick={
a.hive && !destroyed ? () => void toggleWanted(a) : undefined
}
disabled={pending || !a.hive || destroyed}
title={
a.hive
? `click to ${actionLabel} ${a.name}`
: "no hive on record for this agent — nothing to declare against"
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"
}
/>
{destroyable ? (
<Badge
variant="quiet"
tone="negative"
value="destroy"
onClick={() => setDestroyTarget(a)}
disabled={pending}
title={`destroy ${a.name} — tears the container down, irreversible`}
/>
) : null}
{err ? (
<Badge
tone="negative"
@ -373,6 +437,37 @@ export function AgentsPage() {
>
<CreateAgentForm />
</Dialog>
<ConfirmDialog
open={stopTarget !== null}
label="stop agent"
onCancel={() => setStopTarget(null)}
onConfirm={() => stopTarget && void confirmStop(stopTarget)}
confirmLabel="stop"
>
{stopTarget ? (
<p>
Declare <strong>{stopTarget.name}</strong> offline? The hive brings
its container down on its next reconcile sweep.
</p>
) : null}
</ConfirmDialog>
<ConfirmDialog
open={destroyTarget !== null}
label="destroy agent"
onCancel={() => setDestroyTarget(null)}
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
confirmLabel="destroy"
>
{destroyTarget ? (
<p>
Destroy <strong>{destroyTarget.name}</strong>? The hive tears its
container down on its next reconcile sweep. This is not reversible
from here — bringing it back means redeploying via "+ agent", which
reuses the agent's existing identity, config repo, and forge
collaborator access rather than starting over.
</p>
) : null}
</ConfirmDialog>
</Panel>
);
}

View file

@ -0,0 +1,13 @@
/* <ConfirmDialog> — body + button-row layout only; `Dialog.css` owns the
surrounding modal chrome, `Button.css` owns the buttons themselves. */
.confirm-dialog {
display: flex;
flex-direction: column;
gap: 1em;
max-width: 28em;
}
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75em;
}

View file

@ -0,0 +1,55 @@
// <ConfirmDialog> — a `Dialog` pre-wired for the "message + cancel/confirm
// button row" shape every confirm-before-acting flow needs. Extracted out
// of AgentsPage's destroy confirmation on mara's review question ("why the
// extra styling? shouldnt there be a component that does this already?")
// — there wasn't one yet, so that page had grown its own
// `.agents-destroy-confirm`/`-actions` layout CSS for what turns out to be
// a generic shape. `Dialog` itself stays opinion-free about content (see
// its own file-top comment); this is the one layer up that isn't, so a
// future confirm flow gets the button row for free instead of another
// page-scoped CSS file.
//
// The message itself stays caller-owned via `children` — only the dialog
// wiring, spacing, and button row are shared.
import type { ComponentChildren } from "preact";
import { Button } from "../button/Button.js";
import { Dialog } from "../dialog/Dialog.js";
import "./ConfirmDialog.css";
export function ConfirmDialog({
open,
label,
onCancel,
onConfirm,
confirmLabel = "confirm",
cancelLabel = "cancel",
confirmDisabled = false,
children,
}: {
open: boolean;
label: string;
onCancel: () => void;
onConfirm: () => void;
confirmLabel?: string;
cancelLabel?: string;
confirmDisabled?: boolean;
children: ComponentChildren;
}) {
return (
<Dialog open={open} onClose={onCancel} label={label}>
<div class="confirm-dialog">
<div class="confirm-dialog-body">{children}</div>
<div class="confirm-dialog-actions">
<Button onClick={onCancel}>{cancelLabel}</Button>
<Button
variant="primary"
onClick={onConfirm}
disabled={confirmDisabled}
>
{confirmLabel}
</Button>
</div>
</div>
</Dialog>
);
}