diff --git a/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx b/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx index 8713504eb8..beeaf59741 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useNavigate } from "@tanstack/react-router"; +import { IdCard } from "lucide-react"; import { useManagedAgentsQuery, @@ -14,19 +15,21 @@ import { import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { AgentPersona } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; import { Skeleton } from "@/shared/ui/skeleton"; /** - * The passport record for a catalog entry: the live agents running this - * persona in the community, and the commendations the first of them has - * earned. A persona is a definition — its trust record lives on the deployed - * agents, so this section is what turns "Add agent" from a leap of faith into - * a look at the track record. + * The passport record for a catalog entry: the agents running this persona + * in the community, the commendations the first of them has earned, and the + * publisher's own passport. A persona is a definition — its trust record + * lives on the people and agents behind it, so this section is what turns + * "Add agent" from a leap of faith into a look at the track record. * * Deployed agents are resolved from verifiable relay data: the viewer's own - * managed agents link by persona id; other members' agents match when their - * declared operator is the entry's publisher and their name comes from the - * persona's name pool. + * managed agents link by persona id; any community member's agent matches + * when its name comes from the persona's display name or name pool. Names + * are a heuristic, so instances operated by the entry's publisher rank + * first. */ export function PersonaCatalogPassport({ onBeforeNavigate, @@ -38,33 +41,34 @@ export function PersonaCatalogPassport({ const identityQuery = useIdentityQuery(); const selfPubkey = identityQuery.data?.pubkey?.toLowerCase() ?? null; // Catalog entries name their publisher; local/built-in personas are the - // viewer's own, so their agents declare the viewer as operator. + // viewer's own. const publisherPubkey = persona.catalogSource?.ownerPubkey.toLowerCase() ?? selfPubkey; const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); - const agentPubkeys = React.useMemo( - () => (relayAgentsQuery.data ?? []).map((agent) => agent.pubkey), - [relayAgentsQuery.data], + const profilePubkeys = React.useMemo( + () => [ + ...(relayAgentsQuery.data ?? []).map((agent) => agent.pubkey), + ...(publisherPubkey ? [publisherPubkey] : []), + ], + [publisherPubkey, relayAgentsQuery.data], ); - const agentProfilesQuery = useUsersBatchQuery(agentPubkeys); + const profilesQuery = useUsersBatchQuery(profilePubkeys); const deployedAgents = React.useMemo(() => { - const matches = new Map(); + const matches = new Map(); const candidateNames = new Set( [persona.displayName, ...persona.namePool] .map((name) => name.trim().toLowerCase()) .filter((name) => name.length > 0), ); + // Anyone who adds a catalog persona runs their own copy under their own + // key, so any relay agent named from the persona's pool counts as a + // deployment — not just the publisher's. for (const agent of relayAgentsQuery.data ?? []) { - const summary = - agentProfilesQuery.data?.profiles[agent.pubkey.toLowerCase()]; - const declaredOwner = summary?.ownerPubkey?.toLowerCase() ?? null; - if (!publisherPubkey || declaredOwner !== publisherPubkey) { - continue; - } + const summary = profilesQuery.data?.profiles[agent.pubkey.toLowerCase()]; const displayName = summary?.displayName?.trim() || agent.name; if ( candidateNames.has(displayName.toLowerCase()) || @@ -74,6 +78,7 @@ export function PersonaCatalogPassport({ avatarUrl: summary?.avatarUrl ?? null, name: displayName, pubkey: agent.pubkey, + publisherRun: summary?.ownerPubkey?.toLowerCase() === publisherPubkey, }); } } @@ -89,22 +94,30 @@ export function PersonaCatalogPassport({ matches.set(key, { avatarUrl: agent.avatarUrl, name: agent.name, + publisherRun: publisherPubkey === selfPubkey, pubkey: agent.pubkey, }); } } - return [...matches.values()].sort((left, right) => - left.name.localeCompare(right.name), - ); + // Name matches are a heuristic, so the publisher's own instances — the + // strongest provenance — come first and get the badge spotlight. + return [...matches.values()] + .sort( + (left, right) => + Number(right.publisherRun) - Number(left.publisherRun) || + left.name.localeCompare(right.name), + ) + .map(({ publisherRun: _publisherRun, ...agent }) => agent); }, [ - agentProfilesQuery.data, managedAgentsQuery.data, persona.displayName, persona.id, persona.namePool, + profilesQuery.data, publisherPubkey, relayAgentsQuery.data, + selfPubkey, ]); const firstAgent = deployedAgents[0] ?? null; @@ -119,8 +132,21 @@ export function PersonaCatalogPassport({ [navigate, onBeforeNavigate], ); - const isResolving = - relayAgentsQuery.isLoading || agentProfilesQuery.isLoading; + const publisherSummary = publisherPubkey + ? profilesQuery.data?.profiles[publisherPubkey] + : undefined; + const publisherName = + publisherSummary?.displayName?.trim() || + publisherSummary?.name?.trim() || + null; + const publisherLabel = + publisherPubkey === selfPubkey + ? "your" + : publisherName + ? `${publisherName}’s` + : "publisher"; + + const isResolving = relayAgentsQuery.isLoading || profilesQuery.isLoading; return (
Passport

{isResolving ? ( - ) : firstAgent ? ( + ) : (
- - {badges.length > 0 ? ( -
-

- {deployedAgents.length > 1 - ? `Commendations · ${firstAgent.name}` - : "Commendations"} -

- -
+ {firstAgent ? ( + <> +
+

+ Running in this community +

+ +
+ {badges.length > 0 ? ( +
+

+ {deployedAgents.length > 1 + ? `Commendations · ${firstAgent.name}` + : "Commendations"} +

+ +
+ ) : ( +

+ No commendations on record yet — badges appear as this agent + ships work. +

+ )} + ) : ( -

- No commendations on record yet — badges appear as this agent ships - work. +

+ No agent is running this persona in this community right now. Its + record — merged pull requests, reviews, and commendations — + appears here once one is.

)} + {publisherPubkey ? ( +
+ +
+ ) : null}
- ) : ( -

- No deployed agent found for this persona in this community yet. Its - passport record — merged pull requests, reviews, and commendations — - starts once one is running. -

)}
); diff --git a/desktop/tests/e2e/persona-catalog-passport.spec.ts b/desktop/tests/e2e/persona-catalog-passport.spec.ts index 246f248f39..d0af376693 100644 --- a/desktop/tests/e2e/persona-catalog-passport.spec.ts +++ b/desktop/tests/e2e/persona-catalog-passport.spec.ts @@ -90,6 +90,11 @@ test("catalog entry shows the deployed agent's passport record", async ({ passportSection.getByTestId("passport-badge-high-signal"), ).toBeVisible(); + // Own entry: the publisher link points at the viewer's own passport. + await expect( + passportSection.getByTestId("persona-catalog-publisher-passport"), + ).toHaveText(/View your passport/); + await waitForAnimations(page); await page .getByTestId("persona-catalog-detail-pane") @@ -101,6 +106,40 @@ test("catalog entry shows the deployed agent's passport record", async ({ await expect(page.getByTestId("passport-screen")).toBeVisible(); }); +test("another member's deployment matches by persona name", async ({ + page, +}) => { + // Alice publishes the persona, but the running instance ("nadia") is + // operated by the viewer — adoption still shows on the entry. + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + displayName: "nadia", + id: "5".repeat(64), + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: "nadia-from-alice", + systemPrompt: "Shared by alice, run by anyone.", + }), + ], + relayAgents: [ + { + channelNames: ["agents"], + name: "nadia", + pubkey: OWNED_RELAY_AGENT_PUBKEY, + }, + ], + }); + await openCatalog(page); + + const passportSection = page.getByTestId("persona-catalog-passport"); + await expect( + passportSection.getByTestId("passport-agent-chip"), + ).toBeVisible(); + await expect( + passportSection.getByTestId("persona-catalog-publisher-passport"), + ).toHaveText(/View alice’s passport/); +}); + test("entry without a deployed agent shows the empty passport state", async ({ page, }) => { @@ -119,6 +158,10 @@ test("entry without a deployed agent shows the empty passport state", async ({ const emptyState = page.getByTestId("persona-catalog-passport-empty"); await expect(emptyState).toBeVisible(); + // Even with no deployment, the publisher's passport stays one click away. + await expect( + page.getByTestId("persona-catalog-publisher-passport"), + ).toBeVisible(); await waitForAnimations(page); await page