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