swarm-ui: split pages/agents into two pages and the agent components
pages/agents held the roster page, the terminal page and every component under them. It keeps the two pages; the components move to src/agent, by concern: roster (card, wanted menu, actions), detail (panel body, dialog, inference, linked accounts, link menu) and terminal (preview, input, subagents, stream hooks, favicon), with the shared row type and icon at the top. Moves and import rewrites only.
This commit is contained in:
parent
fd6953ed7c
commit
74e588415b
4 changed files with 28 additions and 25 deletions
266
frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx
Normal file
266
frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// useAgentActions — what can be done to an agent from the swarm UI: declare
|
||||
// its wanted state (up / offline / paused / destroyed) and link an account to
|
||||
// it, with the confirm and form dialogs those need. Shared by the agent
|
||||
// roster (`AgentsPage`) and the detail dialog on a terminal page
|
||||
// (`AgentDetailDialog`), so a state change reads the same from either; each
|
||||
// caller renders the returned `dialogs` once.
|
||||
//
|
||||
// "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. Destroy is kept apart: it is irreversible, has a longer warning and
|
||||
// is danger-styled, not one more case of that shape.
|
||||
import { useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { LinkMatrixAccountForm } from "../../pages/LinkMatrixAccountForm.js";
|
||||
import { LinkForgeAccountForm } from "../../pages/LinkForgeAccountForm.js";
|
||||
import { LinkGithubAccountForm } from "../../pages/LinkGithubAccountForm.js";
|
||||
import type { AgentRow } from "../AgentTypes.js";
|
||||
import type { LinkKind } from "../detail/LinkAccountMenu.js";
|
||||
|
||||
// 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** A hive's whole declaration set, as the state PUT answers it. */
|
||||
export type Declared = { agent: string; state: string }[];
|
||||
|
||||
export interface AgentActions {
|
||||
/** Per-agent, not one flag: one row's declare-in-flight must not disable
|
||||
* every other row's button. */
|
||||
pendingAgents: ReadonlySet<string>;
|
||||
actionErrors: ReadonlyMap<string, ProblemDetails>;
|
||||
/** Bumped when a link dialog closes; `LinkedAccounts` refetches on a
|
||||
* change, so a newly linked account shows without a reload. */
|
||||
linkVersion: number;
|
||||
selectUp: (row: AgentRow) => void;
|
||||
selectOffline: (row: AgentRow) => void;
|
||||
selectPaused: (row: AgentRow) => void;
|
||||
requestDestroy: (row: AgentRow) => void;
|
||||
openLink: (row: AgentRow, kind: LinkKind) => void;
|
||||
/** The confirm and link dialogs; render once, anywhere. */
|
||||
dialogs: ComponentChildren;
|
||||
}
|
||||
|
||||
export function useAgentActions(
|
||||
/** Called with the hive's declaration set after a PUT succeeds, so the
|
||||
* caller can patch the `wanted` field into rows already on screen. */
|
||||
onDeclared: (row: AgentRow, declared: Declared) => void,
|
||||
): AgentActions {
|
||||
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [actionErrors, setActionErrors] = useState<
|
||||
ReadonlyMap<string, ProblemDetails>
|
||||
>(new Map());
|
||||
// null means closed, not a boolean, so a dialog can name the agent without
|
||||
// a second piece of state to keep in sync with it.
|
||||
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
|
||||
const [confirmTarget, setConfirmTarget] = useState<{
|
||||
row: AgentRow;
|
||||
state: "offline" | "paused";
|
||||
} | null>(null);
|
||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
||||
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||
const [linkVersion, setLinkVersion] = useState(0);
|
||||
|
||||
const linkClosed = () => setLinkVersion((v) => v + 1);
|
||||
const closeMatrix = () => {
|
||||
setMatrixTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeForge = () => {
|
||||
setForgeTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeGithub = () => {
|
||||
setGithubTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
|
||||
// The one PUT every wanted-state selection ends at, whether or not it went
|
||||
// through a `ConfirmDialog` first: PUT a new `wanted` declaration, track
|
||||
// per-agent pending/error state, hand the response back to the caller.
|
||||
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;
|
||||
}
|
||||
onDeclared(row, (await res.json()) as Declared);
|
||||
} 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeclare() {
|
||||
if (!confirmTarget) return;
|
||||
const { row, state } = confirmTarget;
|
||||
setConfirmTarget(null);
|
||||
await declareState(row, state);
|
||||
}
|
||||
|
||||
// Fires from the destroy confirm dialog, never directly off a click. Unlike
|
||||
// the other directions there is 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");
|
||||
}
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<Dialog
|
||||
open={matrixTarget !== null}
|
||||
onClose={closeMatrix}
|
||||
label="link a matrix account"
|
||||
plain
|
||||
>
|
||||
{/* `matrixTarget.hive` is non-null here — the link triggers are
|
||||
disabled without one, so this can only open with a real hive to
|
||||
PUT against. */}
|
||||
{matrixTarget?.hive ? (
|
||||
<LinkMatrixAccountForm
|
||||
hive={matrixTarget.hive}
|
||||
agent={matrixTarget.name}
|
||||
onClose={closeMatrix}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={forgeTarget !== null}
|
||||
onClose={closeForge}
|
||||
label="link a forge account"
|
||||
plain
|
||||
>
|
||||
{forgeTarget?.hive ? (
|
||||
<LinkForgeAccountForm
|
||||
hive={forgeTarget.hive}
|
||||
agent={forgeTarget.name}
|
||||
onClose={closeForge}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={githubTarget !== null}
|
||||
onClose={closeGithub}
|
||||
label="link a github account"
|
||||
plain
|
||||
>
|
||||
{githubTarget?.hive ? (
|
||||
<LinkGithubAccountForm
|
||||
hive={githubTarget.hive}
|
||||
agent={githubTarget.name}
|
||||
onClose={closeGithub}
|
||||
/>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
pendingAgents,
|
||||
actionErrors,
|
||||
linkVersion,
|
||||
selectUp: (row) => void declareState(row, "up"),
|
||||
selectOffline: (row) => setConfirmTarget({ row, state: "offline" }),
|
||||
selectPaused: (row) => setConfirmTarget({ row, state: "paused" }),
|
||||
requestDestroy: setDestroyTarget,
|
||||
openLink: (row, kind) => {
|
||||
if (kind === "matrix") setMatrixTarget(row);
|
||||
else if (kind === "forge") setForgeTarget(row);
|
||||
else setGithubTarget(row);
|
||||
},
|
||||
dialogs,
|
||||
};
|
||||
}
|
||||
37
frontend/packages/swarm-ui/src/agent/roster/AgentCard.css
Normal file
37
frontend/packages/swarm-ui/src/agent/roster/AgentCard.css
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/* <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;
|
||||
}
|
||||
/* Icon (`AgentIcon`) beside the name/message. */
|
||||
.ui-agent-card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7em;
|
||||
}
|
||||
.ui-agent-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
102
frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx
Normal file
102
frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// <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 { AgentIcon } from "../AgentIcon.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-row">
|
||||
<AgentIcon name={row.name} />
|
||||
<div class="ui-agent-card-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
124
frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx
Normal file
124
frontend/packages/swarm-ui/src/agent/roster/WantedMenu.tsx
Normal 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
|
||||
// `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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue