diff --git a/src/bundled/agents/AgentCard.test.tsx b/src/bundled/agents/AgentCard.test.tsx index ee206e0d3..6e4e9b6db 100644 --- a/src/bundled/agents/AgentCard.test.tsx +++ b/src/bundled/agents/AgentCard.test.tsx @@ -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( + , + ); + expect(screen.getByRole("article", { name: "Agent Solo" })).toHaveClass( + "agent-inventory-row", + ); + expect(screen.getByRole("heading", { level: 4, name: "Solo" })).toBeVisible(); +}); diff --git a/src/bundled/agents/AgentCard.tsx b/src/bundled/agents/AgentCard.tsx index ce894070b..16d7629b4 100644 --- a/src/bundled/agents/AgentCard.tsx +++ b/src/bundled/agents/AgentCard.tsx @@ -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"]; @@ -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(null); const presence = usePresenceStatus( session?.presence, @@ -95,7 +100,7 @@ export function AgentCard({ return (
{onEdit && (
@@ -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} />
-

+ {name} -

+ {children && ( -
{children}
+
+ {children} +
)} {identities.length && !children ? (
diff --git a/src/bundled/agents/AgentControls.css b/src/bundled/agents/AgentControls.css index ee91ea8bc..481b7d716 100644 --- a/src/bundled/agents/AgentControls.css +++ b/src/bundled/agents/AgentControls.css @@ -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; + } +} diff --git a/src/bundled/agents/InventoryIdentityCard.test.tsx b/src/bundled/agents/InventoryIdentityCard.test.tsx index 2c81ff3cf..4c9888528 100644 --- a/src/bundled/agents/InventoryIdentityCard.test.tsx +++ b/src/bundled/agents/InventoryIdentityCard.test.tsx @@ -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} @@ -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(); @@ -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( @@ -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(); @@ -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, ); @@ -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), diff --git a/src/bundled/agents/InventoryIdentityCard.tsx b/src/bundled/agents/InventoryIdentityCard.tsx index f7be71281..d021c7b60 100644 --- a/src/bundled/agents/InventoryIdentityCard.tsx +++ b/src/bundled/agents/InventoryIdentityCard.tsx @@ -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"; @@ -17,6 +18,7 @@ import { type AgentInventoryIdentity, localSetups } from "./inventory-model"; export function InventoryIdentityCard({ row, decision, + community, state, control, session, @@ -33,6 +35,7 @@ export function InventoryIdentityCard({ }: { row: AgentInventoryIdentity; decision: ReturnType; + community: string; state: AgentControlState; control: AgentControl; session: RelaySession; @@ -90,6 +93,8 @@ export function InventoryIdentityCard({ ); return ( )} -
- Identity & sources +
+ + {tile ? ( + "Identity & sources" + ) : ( +
{[...row.knownCommunities] .filter((community) => community && !row.localSetups.has(community)) diff --git a/src/bundled/agents/InventoryView.test.tsx b/src/bundled/agents/InventoryView.test.tsx index 17fd93ce5..1441108b4 100644 --- a/src/bundled/agents/InventoryView.test.tsx +++ b/src/bundled/agents/InventoryView.test.tsx @@ -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"), @@ -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 = [ diff --git a/src/bundled/agents/InventoryView.tsx b/src/bundled/agents/InventoryView.tsx index ff9214c97..34159863b 100644 --- a/src/bundled/agents/InventoryView.tsx +++ b/src/bundled/agents/InventoryView.tsx @@ -8,8 +8,15 @@ import type { } from "../../features/agents/control"; import type { RelaySession } from "../../features/relay/session"; import type { Profile } from "../../features/relay/contracts"; +import { relayOrigin } from "../../features/communities/destination"; import { AgentCard } from "./AgentCard"; -import { inventoryDecision, inventoryGroups } from "./inventory-decisions"; +import { + localHereGroup, + localOtherGroup, + relayGroup, + inventoryDecision, + inventoryGroups, +} from "./inventory-decisions"; import type { AgentInventoryIdentity } from "./inventory-model"; import type { identityTiles } from "./identity-tiles"; @@ -17,6 +24,36 @@ type InventoryEntry = { row: AgentInventoryIdentity; decision: ReturnType; }; +function communitySections(group: string, identities: InventoryEntry[]) { + if (group !== localOtherGroup && group !== relayGroup) + return [{ community: "", identities }]; + const sections = new Map(); + for (const entry of identities) { + // A saved setup owns local placement; relay sightings must not move it. + const communities = + group === localOtherGroup + ? [ + ...entry.row.localSetups.keys(), + ...entry.row.unconfiguredSetups.map((setup) => + setup.relayUrl ? relayOrigin(setup.relayUrl) : "", + ), + ] + : [...entry.row.knownCommunities]; + // First sorted community wins, just as the top-level categories do. + // Keep every setup/association on the one identity row. + const community = communities.filter(Boolean).sort()[0] ?? ""; + const members = sections.get(community) ?? []; + members.push(entry); + sections.set(community, members); + } + return [...sections] + .sort(([a], [b]) => (a && b ? a.localeCompare(b) : a ? -1 : b ? 1 : 0)) + .map(([community, identities]) => ({ + community: community || "Community unknown", + identities, + })); +} + /** Final inventory presentation; discovery and transport lifetime stay with the caller. */ export function InventoryView({ state, @@ -88,33 +125,55 @@ export function InventoryView({ {orderedGroups.map(([group, identities]) => (

{group}

-
- {identities.map(({ row, decision }) => ( - - setSelectedSources((saved) => ({ - ...saved, - [row.pubkey]: source, - })) - } - /> - ))} -
+ {communitySections(group, identities).map( + ({ community, identities }) => ( +
+ {community && ( +

+ {community} +

+ )} +
+ {identities.map(({ row, decision }) => ( + + setSelectedSources((saved) => ({ + ...saved, + [row.pubkey]: source, + })) + } + /> + ))} +
+
+ ), + )}
))} {!!profiles.length && ( diff --git a/src/bundled/agents/ManagedAgentActions.tsx b/src/bundled/agents/ManagedAgentActions.tsx index 9bdd9187f..05e6cd7dc 100644 --- a/src/bundled/agents/ManagedAgentActions.tsx +++ b/src/bundled/agents/ManagedAgentActions.tsx @@ -18,6 +18,7 @@ export function ManagedAgentActions({ imported, destination = "", owner = "", + showCommunity = true, onUseHere, }: { agent: AgentView; @@ -26,6 +27,7 @@ export function ManagedAgentActions({ imported: boolean; destination?: string; owner?: string; + showCommunity?: boolean; onUseHere?: ((pubkey: string, action: "use" | "clone") => void) | undefined; }) { const [settingUp, setSettingUp] = useState(false); @@ -81,9 +83,11 @@ export function ManagedAgentActions({ return (
-

- {agent.relayUrl} -

+ {showCommunity && ( +

+ {agent.relayUrl} +

+ )}

{state.status === "error" && "Last known: "} {agentProcessLabel(agent)}