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:
parent
1e6156d116
commit
8786f24111
1 changed files with 99 additions and 43 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue