Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/bundled/agents/AgentCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,13 @@ it("opens identities in a popover and returns focus on Escape", async () => {
await user.keyboard("{Escape}");
expect(trigger).toHaveFocus();
});

it("keeps the exact identity label and row heading in the final card shell", () => {
render(
<AgentCard name="Solo" identities={[]} layout="row" headingLevel={4} />,
);
expect(screen.getByRole("article", { name: "Agent Solo" })).toHaveClass(
"agent-inventory-row",
);
expect(screen.getByRole("heading", { level: 4, name: "Solo" })).toBeVisible();
});
26 changes: 21 additions & 5 deletions src/bundled/agents/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ export function AgentCard({
onDelete,
children,
identityLabel = (identity) => identity.name,
layout = "tile",
headingLevel = 3,
}: {
children?: ReactNode;
identityLabel?: (identity: { pubkey: string; name: string }) => string;
layout?: "tile" | "row";
headingLevel?: 3 | 4;
name: string;
avatar?: string | undefined;
identities: AgentLibrary["identities"];
Expand All @@ -49,6 +53,7 @@ export function AgentCard({
onDuplicate?: ((agent: AgentView) => void) | undefined;
onDelete?: ((agent: AgentView) => void) | undefined;
}) {
const Heading = headingLevel === 4 ? "h4" : "h3";
const trigger = useRef<HTMLButtonElement>(null);
const presence = usePresenceStatus(
session?.presence,
Expand Down Expand Up @@ -95,7 +100,7 @@ export function AgentCard({
return (
<article
aria-label={`Agent ${name}`}
className={`relative flex min-w-0 flex-col gap-4 rounded-2xl border border-primary ${children ? "p-4" : "px-4 py-8"}`}
className={`relative min-w-0 ${layout === "row" ? "agent-inventory-row" : `flex flex-col gap-4 rounded-2xl border border-primary ${children ? "p-4" : "px-4 py-8"}`}`}
>
{onEdit && (
<div className="absolute right-2 top-2">
Expand Down Expand Up @@ -199,17 +204,28 @@ export function AgentCard({
alt={name}
fallback={name}
src={picture ?? null}
size={children ? "large" : "fill"}
size={layout === "row" ? "default" : children ? "large" : "fill"}
shape="squircle"
statusBadge={presence === "unknown" ? undefined : presence}
/>
</div>
<h3 className="m-0 min-w-0 max-w-full truncate text-label" title={name}>
<Heading
className="m-0 min-w-0 max-w-full truncate text-label"
title={name}
>
{name}
</h3>
</Heading>
</div>
{children && (
<div className="flex min-w-0 flex-col gap-3">{children}</div>
<div
className={
layout === "row"
? `flex min-w-0 flex-wrap items-center gap-2 ${onEdit ? "pr-8" : ""}`
: "flex min-w-0 flex-col gap-3"
}
>
{children}
</div>
)}
{identities.length && !children ? (
<div className="-mt-3 flex justify-center">
Expand Down
62 changes: 62 additions & 0 deletions src/bundled/agents/AgentControls.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,65 @@
.agent-model-popup {
max-height: min(36rem, var(--available-height));
}

.agent-inventory-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.agent-inventory-row + .agent-inventory-row {
border-top: 1px solid var(--border-primary);
}
.agent-inventory-row > :last-child {
justify-content: flex-end;
}
.agent-inventory-row details[open] {
flex-basis: 100%;
}
@media (max-width: 640px) {
.agent-inventory-row {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-2);
}
.agent-inventory-row > :last-child {
justify-content: flex-start;
}
}

.agent-inventory-details > summary {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-control);
list-style: none;
}
.agent-inventory-details > summary::-webkit-details-marker {
display: none;
}
.agent-inventory-details > summary:hover {
background: var(--bg-hover);
}
.agent-inventory-details > summary:focus-visible {
outline: 2px solid var(--border-focus);
}
.agent-inventory-details[open] > summary svg {
transform: rotate(180deg);
}
@media (max-width: 640px) {
.agent-inventory-row .agent-control-field {
flex-basis: 100%;
}
}

@media (max-width: 640px) {
.agent-relay-inventory .agent-inventory-row {
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
}
.agent-relay-inventory .agent-inventory-row > :last-child {
justify-content: flex-end;
}
}
14 changes: 9 additions & 5 deletions src/bundled/agents/InventoryIdentityCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function setup(
key={row.pubkey}
row={row}
decision={inventoryDecision(row, destination)}
community=""
state={{ ...control.snapshot(), status: "ready", data: f.data }}
control={control}
session={owned.session}
Expand Down Expand Up @@ -122,7 +123,7 @@ it("browses durable parked identities while disconnected without reading old fil
expect(within(card).getByText(npubEncode("cd".repeat(32)))).not.toBeVisible();
fireEvent.click(
within(card).queryByText("Identity & sources") ??
within(card).getByText("Identity & sources"),
within(card).getByLabelText(/^Details for /),
);
expect(within(card).getByText(npubEncode("cd".repeat(32)))).toBeVisible();
expect(within(card).getByText("Development Buzz")).toBeVisible();
Expand All @@ -145,7 +146,10 @@ it("offers Clone for a configured other-community setup and keeps its exact cont
const card = await screen.findByRole("article", {
name: "Agent Fixture agent",
});
expect(within(card).getByText("Identity & sources")).toBeVisible();
expect(card).toHaveClass("agent-inventory-row");
expect(
within(card).getByLabelText("Details for Fixture agent"),
).toBeVisible();
// The app runs this setup, so its lifecycle and settings stay reachable here.
expect(within(card).getByRole("button", { name: "Start" })).toBeVisible();
expect(
Expand Down Expand Up @@ -182,7 +186,7 @@ it("makes Import primary and explains how secondary Clone creates a different id
});
const importButton = within(card).getByRole("button", { name: "Import" });
expect(within(card).getByRole("button", { name: "Clone" })).not.toBeVisible();
fireEvent.click(within(card).getByText("Identity & sources"));
fireEvent.click(within(card).getByLabelText(/^Details for /));
const cloneButton = within(card).getByRole("button", { name: "Clone" });
expect(importButton).toBeEnabled();
expect(cloneButton).toBeEnabled();
Expand Down Expand Up @@ -239,7 +243,7 @@ it.each([
expect(!!within(card).queryByRole("button", { name: "Import" })).toBe(
showImport,
);
fireEvent.click(within(card).getByText("Identity & sources"));
fireEvent.click(within(card).getByLabelText(/^Details for /));
expect(!!within(card).queryByRole("button", { name: "Clone" })).toBe(
showClone,
);
Expand Down Expand Up @@ -268,7 +272,7 @@ it("dispatches the chosen source and exact key without importing credentials", (
});
fireEvent.click(within(card).getByRole("button", { name: "Import" }));
expect(onImport).toHaveBeenCalledWith("cd".repeat(32), "development");
fireEvent.click(within(card).getByText("Identity & sources"));
fireEvent.click(within(card).getByLabelText(/^Details for /));
fireEvent.click(within(card).getByRole("button", { name: "Clone" }));
expect(onUseHere).toHaveBeenCalledWith(
"cd".repeat(32),
Expand Down
26 changes: 24 additions & 2 deletions src/bundled/agents/InventoryIdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from "../../features/agents/control";
import type { RelaySession } from "../../features/relay/session";
import type { Profile } from "../../features/relay/contracts";
import { CaretDownIcon } from "../../shared/design-system/icons/index";
import { Button } from "../../shared/design-system/ui/Button";
import { AgentCard } from "./AgentCard";
import { ManagedAgentActions } from "./ManagedAgentActions";
Expand All @@ -17,6 +18,7 @@ import { type AgentInventoryIdentity, localSetups } from "./inventory-model";
export function InventoryIdentityCard({
row,
decision,
community,
state,
control,
session,
Expand All @@ -33,6 +35,7 @@ export function InventoryIdentityCard({
}: {
row: AgentInventoryIdentity;
decision: ReturnType<typeof inventoryDecision>;
community: string;
state: AgentControlState;
control: AgentControl;
session: RelaySession;
Expand Down Expand Up @@ -90,6 +93,8 @@ export function InventoryIdentityCard({
);
return (
<AgentCard
layout={tile ? "tile" : "row"}
headingLevel={community ? 4 : 3}
name={row.displayName}
avatar={avatar}
identities={[{ pubkey: row.pubkey, name: row.displayName }]}
Expand All @@ -103,6 +108,8 @@ export function InventoryIdentityCard({
<ManagedAgentActions
key={agent.id}
agent={agent}
// A section already names its own community; other setups still show theirs.
showCommunity={row.localSetups.get(community) !== agent}
state={state}
control={control}
imported={agent.id === importedId}
Expand Down Expand Up @@ -190,8 +197,23 @@ export function InventoryIdentityCard({
{decision.blocked}
</p>
)}
<details className="min-w-0 text-body-sm text-secondary">
<summary className="cursor-pointer">Identity &amp; sources</summary>
<details
className={
tile
? "min-w-0 text-body-sm text-secondary"
: "agent-inventory-details min-w-0 text-body-sm text-secondary"
}
>
<summary
className="cursor-pointer"
aria-label={tile ? undefined : `Details for ${row.displayName}`}
>
{tile ? (
"Identity & sources"
) : (
<CaretDownIcon size={18} aria-hidden="true" />
)}
</summary>
<div className="flex min-w-0 flex-col gap-2 pt-2">
{[...row.knownCommunities]
.filter((community) => community && !row.localSetups.has(community))
Expand Down
82 changes: 81 additions & 1 deletion src/bundled/agents/InventoryView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ it("renders four exclusive sections with all setups on one exact-key card", asyn
).not.toBeVisible();
fireEvent.click(
within(card).queryByText("Identity & sources") ??
within(card).getByText("Identity & sources"),
within(card).getByLabelText(/^Details for /),
);
expect(
within(card).getByText("Installed Buzz · Development Buzz"),
Expand All @@ -220,6 +220,86 @@ it("renders four exclusive sections with all setups on one exact-key card", asyn
).toBeVisible();
});

it("nests local rows by saved community once, with unknown last", async () => {
setup("connected", (f) => {
const agent = { ...f.agent };
f.data.agents = [
{
...agent,
id: "z",
name: "Several setups",
relayUrl: "wss://z.example",
},
{
...agent,
id: "a",
name: "Several setups",
relayUrl: "wss://a.example",
},
{
...agent,
id: "b",
pubkey: "bb".repeat(32),
name: "Second",
relayUrl: "wss://b.example",
},
{
...agent,
id: "unknown",
pubkey: "aa".repeat(32),
name: "Legacy",
relayUrl: "",
configured: false,
},
];
});
const group = await screen.findByRole("region", {
name: "Local agents in other communities",
});
expect(
within(group)
.getAllByRole("heading", { level: 3 })
.map((h) => h.textContent),
).toEqual(["https://a.example", "https://b.example", "Community unknown"]);
const first = within(group).getByRole("region", {
name: "https://a.example",
});
const row = within(first).getByRole("article", {
name: "Agent Several setups",
});
expect(
screen.getAllByRole("article", { name: "Agent Several setups" }),
).toHaveLength(1);
expect(within(row).getByRole("heading", { level: 4 })).toHaveTextContent(
"Several setups",
);
// Every saved setup of the key keeps its exact controls on the one row.
expect(within(row).getAllByRole("button", { name: "Stop" })).toHaveLength(2);
fireEvent.click(within(row).getByLabelText(/^Details for /));
// The z setup keeps its own controls, so it names its community there.
expect(within(row).getByText("wss://z.example")).toBeVisible();
expect(within(row).queryByText("wss://a.example")).toBeNull();
expect(within(row).queryByRole("button", { name: "Use here" })).toBeNull();
fireEvent.click(within(row).getByLabelText("Details for Several setups"));
expect(within(row).getByRole("button", { name: "Clone" })).toBeVisible();
expect(
within(group).getByRole("region", { name: "Community unknown" }),
).toHaveTextContent("Legacy");
});

it("does not invent a community for library-only relay identities", async () => {
setup("connected", () => {}, []);
const group = await screen.findByRole("region", {
name: "Relay-only agents",
});
const unknown = within(group).getByRole("region", {
name: "Community unknown",
});
expect(
within(unknown).getByRole("article", { name: "Agent Not imported" }),
).toBeVisible();
});

it("retains the chosen source when an identity card unmounts and returns", () => {
const { rows, redraw, onImport } = setup("connected", (f) => {
f.data.parked = [
Expand Down
Loading
Loading