Watch
0
0
Fork
You've already forked hyperhive
0

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

@ -2,7 +2,7 @@
// page component under `./pages/`; this file just maps paths to them.
import { Route, Switch } from "wouter-preact";
import { Shell } from "./shell/Shell.js";
import { AgentsPage } from "./pages/AgentsPage.js";
import { AgentsPage } from "./pages/agents/AgentsPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js";

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>
);
}