swarm-ui/agents: move the split-out agent-page files into pages/agents/

mara, PR review: "make agentspage a subdir now that its split into
sub components". AgentsPage.tsx/.css, AgentCard.tsx/.css,
AgentTypes.ts, and WantedMenu.tsx move as a family into their own
pages/agents/ directory; CreateAgentForm and LinkMatrixAccountForm
stay in pages/ since they aren't part of this split (CreateAgentForm
is still rendered inside AgentsPage's own dialog but is a standalone,
independently-named form, not one of the pieces carved out of the
page itself).

Pure move: relative imports within the new pages/agents/ family are
unchanged (they were always siblings), only the ones reaching back
out to ui/ and the two forms above gained one more '../', plus
App.tsx's route import.
This commit is contained in:
iris 2026-09-11 22:35:44 +02:00
commit 1176de08bd
7 changed files with 14 additions and 14 deletions

View file

@ -0,0 +1,24 @@
/* <AgentCard> content layout the clickable/selectable container
itself is `Card`'s (`ui/card/`); this is just the name/badges/
message arrangement inside one. */
.ui-agent-card-line1 {
display: flex;
align-items: center;
gap: 0.6em;
}
.ui-agent-card-name {
font-weight: 600;
}
/* Pushes the wanted-state control to the row's trailing edge regardless
of how wide the status badge next to it ends up. */
.ui-agent-card-wanted {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-left: auto;
}
.ui-agent-card-message {
color: var(--muted);
white-space: normal;
word-break: break-word;
}

View file

@ -0,0 +1,93 @@
// <AgentCard> — one card per roster agent (mara: "main view: name,
// status, message, wanted" / "message as second line" / "more like
// card per agent") — the FilterableView `view === "cards"` alternative
// to the original `Table`, not its replacement (see `AgentsPage`'s own
// comment). Clicking a card opens a detail panel repeating this same
// info plus what doesn't fit here (hive, config-PR link, matrix
// link-account) — mara: "the info from main list should be included in
// the agent view" too, not just the leftovers.
//
// Built on the shared `Card` primitive (`ui/card/`) for the clickable/
// selectable-container mechanics — the `stopPropagation` wrapper around
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel.
import { Badge } from "@hive/shared/badge.js";
import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css";
export function AgentCard({
row,
pending,
error,
selected,
onSelectUp,
onSelectOffline,
onSelectPaused,
onOpenDetail,
}: {
row: AgentRow;
pending: boolean;
error: ProblemDetails | undefined;
/** This row is the one currently shown in the detail panel a
* highlight only (the panel itself is the source of truth), so a
* page-refresh landing on a still-valid selection reads as obviously
* "that one" rather than a silent selection nothing marks. */
selected: boolean;
onSelectUp: (row: AgentRow) => void;
onSelectOffline: (row: AgentRow) => void;
onSelectPaused: (row: AgentRow) => void;
onOpenDetail: (row: AgentRow) => void;
}) {
const { tone, label } = FRESHNESS[row.freshness];
return (
<Card selected={selected} onClick={() => onOpenDetail(row)}>
<div class="ui-agent-card-line1">
<span class="ui-agent-card-name">{row.name}</span>
<Badge
tone={tone}
title={
row.freshness === "unknown"
? "reported by its hive but not registered in swarm-level identity — needs migration"
: undefined
}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
<span class="ui-agent-card-wanted" onClick={(e) => e.stopPropagation()}>
<WantedMenu
row={row}
pending={pending}
showDestroy={false}
onSelectUp={onSelectUp}
onSelectOffline={onSelectOffline}
onSelectPaused={onSelectPaused}
/>
{error ? (
<Badge
tone="negative"
value="failed"
title={error.detail ?? "the declaration failed"}
/>
) : null}
</span>
</div>
<div class="ui-agent-card-message">
{row.snapshot?.status_text ?? "—"}
</div>
</Card>
);
}

View file

@ -0,0 +1,61 @@
// Wire + presentation types shared across AgentsPage and the components
// it composes (AgentCard, WantedMenu) — split out once those moved to
// their own files (mara: "agentspage is now giant and deserves a
// split") so none of them has to import from AgentsPage.tsx itself.
import type { BadgeTone } from "@hive/shared/badge.js";
export interface ConfigPrStatus {
pr_number: number;
html_url: string | null;
}
export type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
export interface AgentStatusSnapshot {
status_text: string | null;
status_set_at: number | null;
running: boolean;
}
// `"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 renders whatever the wire sends, it
// doesn't validate the enum client-side.
export type Wanted = string | null;
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is*
// the row AgentsPage renders, not a shape assembled from it, so
// there's no separate join-result type to keep in sync with the wire
// contract by hand.
export interface AgentRow {
name: string;
hive: string | null;
freshness: Freshness;
// `age_seconds` deliberately unused here, same reason as HivesPage:
// `RelativeTime` recomputes age client-side from `last_seen_unix`
// rather than rendering a once-computed-at-fetch value.
last_seen_unix: number | null;
snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null;
wanted: Wanted;
}
// Same tone pairing as HivesPage — one freshness enum shared by both
// endpoints, so the same tone rule applies to both pages — but
// `unknown`'s label diverges deliberately: an agent outside the
// swarm-identity roster has a concrete next step (register it), while
// an unrecognized *hive* on HivesPage doesn't carry the same
// actionable meaning.
export const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> =
{
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
// `unknown` means the agent reported into the KV bucket but isn't in
// the swarm-identity roster — a real, distinct case ("hive-reported,
// not yet in swarm level"), not just a fallback default, so it gets a
// label that says what to do about it rather than reusing the enum
// name as the label.
unknown: { tone: "negative", label: "not in swarm identity" },
};

View file

@ -0,0 +1,58 @@
/* Detail panel's "select an agent" empty state. `FilterableView`
(`ui/filterable-view/`) has its own equivalent for its two empty
cases colocated there instead of shared with this one, since the
two components have no other reason to depend on each other's CSS. */
.ui-agents-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
}
/* Two-way view switcher in the Panel's title-row actions same visual
weight as `RefreshIntervalPicker` next to it (quiet until touched),
but a segmented pair rather than a select since there's no listbox
worth opening for a two-option choice. */
.ui-agents-view-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 0.4em;
overflow: hidden;
}
.ui-agents-view-toggle button {
border: none;
background: none;
color: var(--muted);
padding: 0.45em 0.75em;
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
.ui-agents-view-toggle button + button {
border-left: 1px solid var(--border);
}
.ui-agents-view-toggle button.active {
background: var(--bg-elev);
color: var(--fg);
}
.ui-agents-view-toggle button:not(.active):hover {
color: var(--fg);
}
/* Detail panel body its own `Panel`, title supplied by the panel's
`title` prop (the agent's name), not repeated in here. */
.ui-agent-detail-fields {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4em 1em;
margin: 0 0 1em;
}
.ui-agent-detail-fields dt {
color: var(--muted);
}
.ui-agent-detail-fields dd {
margin: 0;
}
.ui-agent-detail-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -0,0 +1,647 @@
// <AgentsPage> — the swarm's agent roster, merged with each agent's open
// config-PR status, swarm-wide health status, and declared wanted state.
// A roster with no per-row detail is thin, and neither a config-PR nor a
// status panel has anything to render against without a roster to embed
// in — so all three ended up as one page.
//
// One fetch, on a refresh-interval cadence like HivesPage: `GET
// /api/agents/status` — one row per roster agent (identity store is the
// roster, so a never-reported agent still gets a row), each already
// carrying its freshness/snapshot/config PR/wanted state. Used to be
// three separate fetches joined client-side by name; per operator
// review feedback ("the view should be filled by a single backend
// call") the join moved server-side instead — see
// `swarm-controller/src/main.rs`'s `get_agents_status` handler. No more
// per-page joining left to do here: the wire row *is* the row this page renders.
//
// Owns the "+ agent" trigger too — the roster this populates is the
// natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
//
// This file owns the page's own state/actions/`columns` only —
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
// (mara: "agentspage is now giant and deserves a split"). Filtering +
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
// see its own comment) — `viewMode` here just says which one to show.
import { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import { AgentCard } from "./AgentCard.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { Button } from "../../ui/button/Button.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { Panel } from "../../ui/panel/Panel.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
useRefreshInterval,
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { SplitView } from "../../ui/split-view/SplitView.js";
import { type TableColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js";
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentsPage.css";
// The one thing that differs between the "offline" and "paused" confirm
// dialogs `confirmTarget` drives — everything else (button row, open/close
// wiring) is the shared `ConfirmDialog`.
const CONFIRM_COPY: Record<
"offline" | "paused",
{
label: string;
confirmLabel: string;
message: (agentName: string) => ComponentChildren;
}
> = {
offline: {
label: "stop agent",
confirmLabel: "stop",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> offline? The hive brings its
container down on its next reconcile sweep.
</p>
),
},
paused: {
label: "pause agent",
confirmLabel: "pause",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> paused? Its container stays up
the web UI and MCP daemons keep running but its turn loop parks on the
hive's next reconcile sweep. Select "up" again to resume it.
</p>
),
},
};
// Same 30s default + same reasoning as HivesPage: no inputs on this page
// for a refresh to clobber, so the out-of-the-box behaviour should just
// solve staleness rather than require an opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
type ViewMode = "cards" | "table";
// Persisted like `Table`'s own per-column filters (`storageKey` below) —
// a chosen view is a standing preference, not a per-visit default.
const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
// Lazy initializer (not a mount effect) so the very first render
// already reflects the stored choice — no default-then-flip flash.
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
try {
return localStorage.getItem(VIEW_MODE_KEY) === "table"
? "table"
: "cards";
} catch {
return "cards";
}
});
function setViewMode(mode: ViewMode) {
setViewModeState(mode);
try {
localStorage.setItem(VIEW_MODE_KEY, mode);
} catch {
/* localStorage unavailable — choice is session-only */
}
}
const [createOpen, setCreateOpen] = useState(false);
// Per-agent, not one page-wide flag: one row's declare-in-flight
// shouldn't disable every other row's button.
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
new Set(),
);
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. Kept apart from
// `confirmTarget` below rather than folded into it as a third state:
// destroy is categorically different (irreversible, longer warning,
// danger-styled), not one more case of the same "confirm before parking
// a running agent" shape "offline"/"paused" share.
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
// "offline" and "paused" are the *same* confirm shape — same button
// treatment, same "disruptive to a running turn loop, unlike up" reason
// for needing a confirm at all — so one union state + one `ConfirmDialog`
// covers both instead of two near-identical copies (argus, PR review: "a
// shared confirm-dialog-body-generator would drop real line count").
// `CONFIRM_COPY` below supplies the one thing that differs.
const [confirmTarget, setConfirmTarget] = useState<{
row: AgentRow;
state: "offline" | "paused";
} | null>(null);
// The row currently showing the "link a matrix account" dialog — same
// null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of
// state rather than folded into either since this dialog isn't a
// confirmation of a `declareState` call, it's an unrelated action with
// its own form (`LinkMatrixAccountForm`).
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
// Which agent the detail panel shows, *by name* — not the `AgentRow`
// object itself. Storing the row would snapshot it at selection time;
// `rows` replaces its whole array on every `refresh()` and every
// `declareState` patch, so a captured object goes stale the moment
// either fires (argus, PR review: "open a detail panel, wait for the
// next refresh or destroy from inside the panel itself — the card
// list updates live, the panel next to it doesn't"). `detailTarget`
// below re-derives the live row from `rows` every render instead, so
// it can't drift from what the list is showing.
const [detailTargetName, setDetailTargetName] = useState<string | null>(null);
const detailTarget = rows?.find((r) => r.name === detailTargetName) ?? null;
async function refresh() {
const res = await fetch("/api/agents/status");
if (!res.ok) {
setError(await readApiError(res));
return;
}
setRows((await res.json()) as AgentRow[]);
// A refresh that succeeds clears a previous failure — otherwise a
// transient error would sit on screen forever after the data itself
// has recovered.
setError(null);
}
useRefreshInterval(intervalMs, () => {
refresh().catch((e: unknown) => setError({ detail: String(e) }));
});
// 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));
setActionErrors((prev) => {
const next = new Map(prev);
next.delete(row.name);
return next;
});
try {
const res = await fetch(
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ state: target }),
},
);
if (!res.ok) {
const problem = await readApiError(res);
setActionErrors((prev) => new Map(prev).set(row.name, problem));
return;
}
// The PUT response is the hive's whole declaration set (every
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
// returns — so patch just that field into the rows already on
// screen rather than firing a second, full `/api/agents/status`
// round-trip merely to learn one hive's `wanted` column changed.
// Everything else on the grid (freshness, snapshot, config PR)
// still catches up on the next interval-driven `refresh()`.
const declared = (await res.json()) as { agent: string; state: string }[];
setRows((prev) => {
if (!prev) return prev;
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
return prev.map((r) =>
r.hive === row.hive && byAgent.has(r.name)
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
: r,
);
});
} catch (e: unknown) {
setActionErrors((prev) =>
new Map(prev).set(row.name, { detail: String(e) }),
);
} finally {
setPendingAgents((prev) => {
const next = new Set(prev);
next.delete(row.name);
return next;
});
}
}
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
// and "paused" — see that state's own comment). Used to be a native
// `window.confirm` for the "offline" case (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 confirmDeclare() {
if (!confirmTarget) return;
const { row, state } = confirmTarget;
setConfirmTarget(null);
await declareState(row, state);
}
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
// 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");
}
// The three non-destroy `WantedMenu` selections, factored out once
// rather than repeated per call site — the card, the table's "wanted"
// column, and the detail panel's own `WantedMenu` (below) all wire the
// same three.
const selectUp = (row: AgentRow) => void declareState(row, "up");
const selectOffline = (row: AgentRow) =>
setConfirmTarget({ row, state: "offline" });
const selectPaused = (row: AgentRow) =>
setConfirmTarget({ row, state: "paused" });
// `FilterableView`'s `columns` — the full field set (including
// per-column sort/filter, which drives both the table's own popovers
// and the card view's filter bar now — see that component's own
// comment). Table's own `WantedMenu` keeps the default `showDestroy`
// (the table's cells have room for the fourth option the card view
// moved out to its detail panel).
const columns: TableColumn<AgentRow>[] = [
{
key: "name",
header: "name",
render: (a) => a.name,
sortBy: (a) => a.name,
filterValue: (a) => a.name,
},
{
key: "hive",
header: "hive",
render: (a) => a.hive ?? "—",
sortBy: (a) => a.hive ?? "",
filterValues: (a) => [a.hive ?? "—"],
filterMode: "multiselect",
},
{
key: "status",
header: "status",
sortBy: (a) => FRESHNESS[a.freshness].label,
filterValues: (a) => [FRESHNESS[a.freshness].label],
filterMode: "multiselect",
render: (a) => {
const { tone, label } = FRESHNESS[a.freshness];
return (
<Badge
tone={tone}
title={
a.freshness === "unknown"
? "reported by its hive but not registered in swarm-level identity — needs migration"
: undefined
}
value={
<>
{label}
{a.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
);
},
},
{
key: "message",
header: "message",
cellClass: "ui-table-prose",
render: (a) => a.snapshot?.status_text ?? "—",
filterValue: (a) => a.snapshot?.status_text ?? "",
},
{
key: "wanted",
header: "wanted",
sortBy: (a) => a.wanted ?? "",
filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect",
render: (a) => {
const err = actionErrors.get(a.name);
return (
<>
<WantedMenu
row={a}
pending={pendingAgents.has(a.name)}
portal
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
{err ? (
<Badge
tone="negative"
value="failed"
title={err.detail ?? "the declaration failed"}
/>
) : null}
</>
);
},
},
{
key: "matrix",
header: "matrix",
render: (a) => (
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link account"
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
disabled={!a.hive}
title={
a.hive
? `link a matrix account to ${a.name}`
: "no hive on record for this agent — nothing to link against"
}
/>
),
},
{
key: "config-pr",
header: "config PR",
sortBy: (a) => a.config_pr?.pr_number ?? 0,
filterValue: (a) => (a.config_pr ? `#${a.config_pr.pr_number}` : ""),
render: (a) =>
a.config_pr ? (
<Badge
tone="warning"
value={
a.config_pr.html_url ? (
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
#{a.config_pr.pr_number}
</a>
) : (
`#${a.config_pr.pr_number}`
)
}
/>
) : (
"—"
),
},
];
return (
<>
<SplitView
primary={
<Panel
title="agents"
icon="👥"
actions={
<>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<div
class="ui-agents-view-toggle"
role="group"
aria-label="view"
>
<button
type="button"
aria-pressed={viewMode === "cards"}
class={viewMode === "cards" ? "active" : undefined}
onClick={() => setViewMode("cards")}
>
cards
</button>
<button
type="button"
aria-pressed={viewMode === "table"}
class={viewMode === "table" ? "active" : undefined}
onClick={() => setViewMode("table")}
>
table
</button>
</div>
<RefreshIntervalPicker
id="agents-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
>
{error ? (
<ApiErrorPanel
context="failed to load the agent roster"
problem={error}
/>
) : null}
{!error && rows === null ? <p>loading</p> : null}
{rows ? (
<FilterableView
columns={columns}
rows={rows}
rowKey={(a) => a.name}
storageKey="swarm-ui:agents:table-filters"
view={viewMode}
emptyMessage="no agents yet — the swarm-wide identity store has no agents registered on any hive"
renderCard={(a) => (
<AgentCard
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
selected={detailTargetName === a.name}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
)}
/>
) : null}
</Panel>
}
// A second on-page `Panel`, not a modal — mara: "why no separate
// panel? ... i mean a second panel on agent page." Always mounted
// (an empty state when nothing's selected) rather than
// conditionally rendered, so picking an agent never causes the
// page's own layout to jump. `SplitView`'s `flex-wrap` stacks
// this below the list on a narrow viewport, same content-driven-
// not-a-fixed-breakpoint approach the shell's own nav already
// uses (`Shell.css`), rather than a new media query.
secondary={
<Panel
title={detailTarget ? detailTarget.name : "agent details"}
icon="🔎"
>
{detailTarget ? (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
{(() => {
const { tone, label } = FRESHNESS[detailTarget.freshness];
return (
<Badge
tone={tone}
value={
<>
{label}
{detailTarget.last_seen_unix !== null ? (
<>
{" "}
(
<RelativeTime
epochMs={detailTarget.last_seen_unix * 1000}
/>
)
</>
) : null}
</>
}
/>
);
})()}
</dd>
<dt>message</dt>
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) mara: "destroy is
already available via wanted state", no separate
button needed. */}
<WantedMenu
row={detailTarget}
pending={pendingAgents.has(detailTarget.name)}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
</dd>
<dt>hive</dt>
<dd>{detailTarget.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{detailTarget.config_pr ? (
<Badge
tone="warning"
value={
detailTarget.config_pr.html_url ? (
<a
href={detailTarget.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{detailTarget.config_pr.pr_number}
</a>
) : (
`#${detailTarget.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link matrix account"
onClick={
detailTarget.hive
? () => setMatrixTarget(detailTarget)
: undefined
}
disabled={!detailTarget.hive}
title={
detailTarget.hive
? `link a matrix account to ${detailTarget.name}`
: "no hive on record for this agent — nothing to link against"
}
/>
</div>
</div>
) : (
<p class="ui-agents-empty">select an agent to see its details</p>
)}
</Panel>
}
/>
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
label="create agent"
>
<CreateAgentForm />
</Dialog>
<Dialog
open={matrixTarget !== null}
onClose={() => setMatrixTarget(null)}
label="link a matrix account"
>
{/* `matrixTarget.hive` is non-null here the trigger badge above
is disabled without one, so this can only open with a real
hive to PUT against. */}
{matrixTarget?.hive ? (
<LinkMatrixAccountForm
hive={matrixTarget.hive}
agent={matrixTarget.name}
/>
) : null}
</Dialog>
<ConfirmDialog
open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
onCancel={() => setConfirmTarget(null)}
onConfirm={() => void confirmDeclare()}
confirmLabel={
confirmTarget
? CONFIRM_COPY[confirmTarget.state].confirmLabel
: undefined
}
>
{confirmTarget
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
: 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>
</>
);
}

View file

@ -0,0 +1,124 @@
// <WantedMenu> — the "wanted" state control: one badge showing the
// current declaration, opening a `Dropdown` with the four 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.
//
// "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 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}
value={pending ? "…" : (row.wanted ?? "no declaration")}
// `destroyed` blocks opening the menu at all — same UX-only
// guard as before (real enforcement is server-side, see
// `declareState`'s caller), just applied to the trigger instead
// of a `disabled` toggle badge.
onClick={row.hive && !destroyed ? () => 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"
}
/>
<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>
);
}