swarm-ui/agents: detail panel shows full agent state, fix dropdown-behind-dialog bug

Three things from mara's PR review + argus's:

- "the info from main list should be included in the agent view" - detail
  panel now repeats status/message/wanted alongside the panel-only fields
  (hive, config PR, matrix link), not just the leftovers.
- "destroy is already available via wanted state" - dropped the standalone
  "destroy agent" button; the detail panel's wanted field is a real
  WantedMenu (default showDestroy) instead, same control as the card/table,
  just with the fourth option back. Also resolves argus's stale-doc-comment
  finding (the comment described a second WantedMenu call site that didn't
  exist yet - now it does).
- Rebased onto main to pick up the just-merged dropdown-portal-clip fix -
  this branch was cut before that merged, so it had silently regressed
  back to the pre-fix Dropdown the whole time.

That rebase surfaced a real bug of its own, likely "the third screenshot
shows a layout bug": WantedMenu always passed `portal` to Dropdown, and a
portaled (position: fixed, body-appended) dropdown 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. Only
the sliver of the dropdown extending past the dialog's own edge was
visible. WantedMenu's `portal` is now its own prop, opt-in, true only at
the table's call site (the one with an actual clipping ancestor to escape)
- card and detail-panel call sites render it as a plain child instead,
which is both correct inside the dialog and one fewer moving part where
it isn't needed.

Verified with real CDP clicks: detail panel shows all fields, and its
wanted dropdown now renders in the right place with all four options
visible instead of mostly hidden behind the dialog.
This commit is contained in:
iris 2026-09-11 21:53:01 +02:00
commit 8786f24111

View file

@ -174,6 +174,7 @@ function WantedMenu({
onSelectPaused,
onDestroy,
showDestroy = true,
portal = false,
}: {
row: AgentRow;
pending: boolean;
@ -182,15 +183,29 @@ function WantedMenu({
onSelectPaused: (row: AgentRow) => void;
onDestroy?: (row: AgentRow) => void;
/**
* The card view (see `AgentCard` below) keeps this menu to the
* three everyday states and gives "destroy" the rare, one-way
* one its own button in the detail panel instead, so it can't be
* reached by an extra click off a state that's already open (mara,
* scoping the card/detail split: destroy belongs with the "rarely
* used actions" bucket, not next to up/paused/offline). The
* `WantedMenu` inside that detail panel still gets the full set.
* The card view (see `AgentCard` below) 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);
@ -234,7 +249,7 @@ function WantedMenu({
/>
<Dropdown
open={open}
portal
portal={portal}
options={options}
activeValue={row.wanted ?? undefined}
label={`declare ${row.name}`}
@ -255,13 +270,14 @@ function WantedMenu({
// One card per roster agent (mara: "main view: name, status, message,
// wanted" / "message as second line" / "more like card per agent") —
// the `viewMode === "cards"` alternative to the original `Table`, not
// its replacement (see `AgentsPage`'s own comment). Everything the
// table's other columns carried (hive, matrix link-account, config-PR
// link, destroy) moves to a detail panel on card click instead — a
// plain `Dialog`, not the shared `hive-side-panel` slide-in drawer:
// that's a shadow-DOM custom element, and swarm-ui's esbuild config
// can't consume those yet (same gap `Dialog.tsx`'s own comment already
// flags for `hive-dialog`). Revisit once that gap closes.
// 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. A plain `Dialog`, not the shared `hive-side-panel`
// slide-in drawer: that's a shadow-DOM custom element, and swarm-ui's
// esbuild config can't consume those yet (same gap `Dialog.tsx`'s own
// comment already flags for `hive-dialog`). Revisit once that closes.
//
// A `role="button"` div, not a real `<button>`: the card also hosts the
// real `<button>`s inside `WantedMenu`, and nested buttons are invalid
@ -404,9 +420,8 @@ export function AgentsPage() {
// confirmation of a `declareState` call, it's an unrelated action with
// its own form (`LinkMatrixAccountForm`).
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
// The row showing the detail panel (hive, matrix link-account,
// config-PR link, destroy — everything `AgentCard`'s main view
// doesn't) — same null-means-closed shape as the two above.
// The row showing the detail panel (see the `Dialog` below for what
// it holds) — same null-means-closed shape as the two above.
const [detailTarget, setDetailTarget] = useState<AgentRow | null>(null);
async function refresh() {
@ -509,6 +524,16 @@ export function AgentsPage() {
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" });
// `viewMode === "table"`'s columns — the full field set (including
// per-column sort/filter, which the card view doesn't have) mara asked
// to keep. Table's own `WantedMenu` keeps the default `showDestroy`
@ -581,13 +606,10 @@ export function AgentsPage() {
<WantedMenu
row={a}
pending={pendingAgents.has(a.name)}
onSelectUp={(row) => void declareState(row, "up")}
onSelectOffline={(row) =>
setConfirmTarget({ row, state: "offline" })
}
onSelectPaused={(row) =>
setConfirmTarget({ row, state: "paused" })
}
portal
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
{err ? (
@ -700,13 +722,9 @@ export function AgentsPage() {
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
onSelectUp={(row) => void declareState(row, "up")}
onSelectOffline={(row) =>
setConfirmTarget({ row, state: "offline" })
}
onSelectPaused={(row) =>
setConfirmTarget({ row, state: "paused" })
}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onOpenDetail={setDetailTarget}
/>
))}
@ -725,12 +743,59 @@ export function AgentsPage() {
onClose={() => setDetailTarget(null)}
label={detailTarget ? `${detailTarget.name} details` : "agent details"}
>
{/* Everything `AgentCard`'s main view doesn't already show see
that component's own comment for the field split. */}
{/* Mara: "the info from main list should be included in the
agent view" so this repeats status/message/wanted rather
than showing only what `AgentCard`'s main view doesn't, plus
hive/config-PR/matrix-link, which really are panel-only. */}
{detailTarget ? (
<div class="ui-agent-detail">
<h2 class="ui-agent-detail-name">{detailTarget.name}</h2>
<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={(row) => {
setDestroyTarget(row);
setDetailTarget(null);
}}
/>
</dd>
<dt>hive</dt>
<dd>{detailTarget.hive ?? "—"}</dd>
<dt>config PR</dt>
@ -777,15 +842,6 @@ export function AgentsPage() {
: "no hive on record for this agent — nothing to link against"
}
/>
<Button
disabled={detailTarget.wanted === "destroyed"}
onClick={() => {
setDestroyTarget(detailTarget);
setDetailTarget(null);
}}
>
destroy agent
</Button>
</div>
</div>
) : null}