Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the agents page is one column of cards that open in place

The roster loses its second column and its table view. Selecting a card opens
its details under it as four folded sections: overview, settings, accounts and
the terminal. The status, message and wanted rows, which the card header
already shows, are gone from the details. The terminal page's details dialog
keeps the flat list.
This commit is contained in:
iris 2026-11-01 01:21:43 +01:00
commit 2eaa02e892
10 changed files with 555 additions and 541 deletions

View file

@ -17,3 +17,11 @@
align-items: center;
gap: 0.75em;
}
/* The folded sections of an opened card sit one under another, each with the
shared disclosure look; the fields keep the grid above. */
.ui-agent-detail-sections {
display: flex;
flex-direction: column;
gap: 0.75em;
}

View file

@ -1,8 +1,11 @@
// <AgentDetail> — one agent's details: its status, wanted state, hive, config
// PR, inference settings, resource caps and linked accounts, plus the actions on them. The
// body of the detail panel on `/agents` (where `withTerminal` adds the live
// terminal and subagents below it) and of the detail dialog on a terminal
// page (which is already beside the terminal, so omits it).
// PR, inference settings, resource caps and linked accounts, plus the actions on
// them. Two layouts. In an opened card on `/agents` (`inCard`) the card header
// already shows status, message and wanted state, so the body is four folded
// sections (`sections.ts`): overview, settings, accounts, and the terminal with
// its subagents, each built when first opened. In the detail dialog on a terminal
// page it is one flat list with those three rows, and no terminal, since the
// page is already beside one.
//
// Takes the agent's *live* row and the shared `AgentActions`, so a state
// change or a link made here shows wherever else the same row is drawn.
@ -20,140 +23,262 @@ import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import {
SECTION_TITLE,
sectionsFor,
startsOpen,
type DetailSection,
} from "./sections.js";
import { SubagentTerms } from "../terminal/SubagentTerms.js";
import { WantedMenu } from "../roster/WantedMenu.js";
import "./AgentDetail.css";
export function AgentDetail({
/** The rows the card header already shows: status, message and wanted. */
function HeaderRows({
row,
actions,
withTerminal = false,
}: {
row: AgentRow;
actions: AgentActions;
withTerminal?: boolean;
}) {
const { tone, label } = FRESHNESS[row.freshness];
const declareError = actions.actionErrors.get(row.name);
return (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
<>
<dt>status</dt>
<dd>
<Badge
tone={tone}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
</dd>
<dt>message</dt>
<dd>{row.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={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{/* A failed declaration shows here too: on a terminal page's
dialog there is no roster card to carry the "failed" badge. */}
{declareError ? (
<ApiErrorPanel
context="the declaration failed"
problem={declareError}
/>
) : null}
</dd>
</>
);
}
function OverviewRows({ row }: { row: AgentRow }) {
return (
<>
<dt>state</dt>
<dd>
<AgentStateRow key={row.name} agent={row.name} />
</dd>
<dt>hive</dt>
<dd>{row.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{row.config_pr ? (
<Badge
tone={tone}
tone="warning"
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
row.config_pr.html_url ? (
<a
href={row.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{row.config_pr.pr_number}
</a>
) : (
`#${row.config_pr.pr_number}`
)
}
/>
</dd>
<dt>message</dt>
<dd>{row.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={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
) : (
"—"
)}
</dd>
</>
);
}
function SettingsRows({ agent }: { agent: string }) {
return (
<>
<dt>inference</dt>
<dd>
<AgentInference key={agent} agent={agent} />
</dd>
<dt>instructions</dt>
<dd>
<AgentInstructions key={agent} agent={agent} />
</dd>
<dt>resource caps</dt>
<dd>
<AgentLimits key={agent} agent={agent} />
</dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={agent} agent={agent} />
</dd>
<dt>allowed domains</dt>
<dd>
<AllowedDomains key={agent} agent={agent} />
</dd>
</>
);
}
function AccountsRows({
row,
actions,
}: {
row: AgentRow;
actions: AgentActions;
}) {
return (
<>
<dt>accounts</dt>
<dd>
{row.hive ? (
<LinkedAccounts
key={`${row.hive}/${row.name}`}
hive={row.hive}
agent={row.name}
version={actions.linkVersion}
/>
{/* A failed declaration shows here too: on a terminal page's
dialog there is no roster card to carry the "failed" badge. */}
{declareError ? (
<ApiErrorPanel
context="the declaration failed"
problem={declareError}
/>
) : null}
</dd>
<dt>state</dt>
<dd>
<AgentStateRow key={row.name} agent={row.name} />
</dd>
<dt>hive</dt>
<dd>{row.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{row.config_pr ? (
<Badge
tone="warning"
value={
row.config_pr.html_url ? (
<a
href={row.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{row.config_pr.pr_number}
</a>
) : (
`#${row.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
<dt>inference</dt>
<dd>
<AgentInference key={row.name} agent={row.name} />
</dd>
<dt>instructions</dt>
<dd>
<AgentInstructions key={row.name} agent={row.name} />
</dd>
<dt>resource caps</dt>
<dd>
<AgentLimits key={row.name} agent={row.name} />
</dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={row.name} agent={row.name} />
</dd>
<dt>allowed domains</dt>
<dd>
<AllowedDomains key={row.name} agent={row.name} />
</dd>
<dt>accounts</dt>
<dd>
{row.hive ? (
<LinkedAccounts
key={`${row.hive}/${row.name}`}
hive={row.hive}
agent={row.name}
version={actions.linkVersion}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={row.name}
hasHive={row.hive !== null}
onSelect={(kind) => actions.openLink(row, kind)}
/>
</div>
{withTerminal ? (
) : (
"—"
)}
</dd>
</>
);
}
function LinkAccountAction({
row,
actions,
}: {
row: AgentRow;
actions: AgentActions;
}) {
return (
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={row.name}
hasHive={row.hive !== null}
onSelect={(kind) => actions.openLink(row, kind)}
/>
</div>
);
}
/** The body of one folded section of an opened card. */
function SectionBody({
section,
row,
actions,
}: {
section: DetailSection;
row: AgentRow;
actions: AgentActions;
}) {
switch (section) {
case "overview":
return (
<dl class="ui-agent-detail-fields">
<OverviewRows row={row} />
</dl>
);
case "settings":
return (
<dl class="ui-agent-detail-fields">
<SettingsRows agent={row.name} />
</dl>
);
case "accounts":
return (
<>
<dl class="ui-agent-detail-fields">
<AccountsRows row={row} actions={actions} />
</dl>
<LinkAccountAction row={row} actions={actions} />
</>
);
case "terminal":
return (
<>
{/* The agent's live terminal and its input box. */}
<AgentTermPreview agentName={row.name} />
<SubagentTerms key={row.name} agentName={row.name} />
</>
);
}
}
export function AgentDetail({
row,
actions,
withTerminal = false,
inCard = false,
}: {
row: AgentRow;
actions: AgentActions;
/** Add the live terminal and subagents: for the flat layout, below the rows. */
withTerminal?: boolean;
/** The folded sections of an opened card, without the rows its header shows. */
inCard?: boolean;
}) {
if (inCard) {
return (
<div class="ui-agent-detail ui-agent-detail-sections">
{sectionsFor(withTerminal).map((section) => (
<LazyDisclosure
key={section}
title={SECTION_TITLE[section]}
startOpen={startsOpen(section)}
>
<SectionBody section={section} row={row} actions={actions} />
</LazyDisclosure>
))}
</div>
);
}
return (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<HeaderRows row={row} actions={actions} />
<OverviewRows row={row} />
<SettingsRows agent={row.name} />
<AccountsRows row={row} actions={actions} />
</dl>
<LinkAccountAction row={row} actions={actions} />
{withTerminal ? (
<SectionBody section="terminal" row={row} actions={actions} />
) : null}
</div>
);

View file

@ -1,4 +1,4 @@
// <AgentDetailPopover> — the agent detail panel from `/agents`, in a popover
// <AgentDetailPopover> — an agent's details from `/agents`, as in an opened card, in a popover
// under its anchor, for a page that is already showing the agent's terminal
// and should not be left, or dimmed, to read or change its details. It carries
// the same body (`AgentDetail`) and the same actions (`useAgentActions`) as the

View file

@ -1,4 +1,4 @@
// <AgentInference> — one agent's inference settings on the detail panel: the
// <AgentInference> — one agent's inference settings in an opened agent card: the
// model and effort it runs on now, with where each came from, and an editor
// for the value the swarm sets.
//

View file

@ -1,5 +1,5 @@
// <AgentInstructions> — an editor for an agent's AGENTS.md, in a dialog opened
// from the agent's detail panel.
// from the agent's opened card.
//
// It loads the copy the agent's harness last published
// (`GET /api/agents/{name}/agents-md`, so a stopped agent shows the text it

View file

@ -1,4 +1,4 @@
// <AgentLimits> — one agent's resource caps on the detail panel: the CPU and
// <AgentLimits> — one agent's resource caps in an opened agent card: the CPU and
// memory caps the swarm sets for it, and an editor for them.
//
// Reads `GET /api/agents/{name}/limits`, which answers the stored caps as

View file

@ -1,4 +1,4 @@
// <AgentPermissions> — one agent's permissions on the detail panel: what the
// <AgentPermissions> — one agent's permissions in an opened agent card: what the
// swarm grants it, and an editor for that.
//
// Reads `GET /api/agents/{name}/permissions`, which answers the stored list as

View file

@ -1,4 +1,4 @@
// The detail panel's allowed-domains editor: which entries one agent's panel
// The allowed-domains editor of an opened agent card: which entries it
// shows and the bodies it sends. Kept out of the component so `npm test` can
// run it.

View file

@ -1,4 +1,4 @@
// The detail panel's permissions editor: what it starts on and how a granted
// An opened agent card's permissions editor: what it starts on and how a granted
// set reads. Kept out of the component so `npm test` can run it.
/**

View file

@ -0,0 +1,28 @@
// The sections an agent card opens into, without a DOM: which there are, in
// what order, and which start open. The card's header already shows the
// agent's status, message and wanted state, so the sections hold the rest.
/** One folded block of an opened agent card. */
export type DetailSection = "overview" | "settings" | "accounts" | "terminal";
/** What each section's header says. */
export const SECTION_TITLE: Record<DetailSection, string> = {
overview: "overview",
settings: "settings",
accounts: "accounts",
terminal: "terminal",
};
/**
* The sections a card shows, top to bottom. The terminal is left out where
* the page is already beside one (the terminal page's details dialog).
*/
export function sectionsFor(withTerminal: boolean): DetailSection[] {
const sections: DetailSection[] = ["overview", "settings", "accounts"];
return withTerminal ? [...sections, "terminal"] : sections;
}
/** Only the overview is open when a card opens; the rest build on demand. */
export function startsOpen(section: DetailSection): boolean {
return section === "overview";
}