fix(desktop): surface catalog passport data with real community entries

Deployed-agent matching required the instance's declared operator to be
the entry's publisher, but anyone who adds a persona runs their own copy
under their own key — so real catalog entries always fell into the empty
state. Match any community agent named from the persona's pool (publisher-
run instances still rank first for the badge spotlight), and always offer
the publisher's passport link so every entry has a passport trail even
before anyone deploys it.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-04 16:37:02 +02:00
parent 1dae17b592
commit 594c2fc6d7
2 changed files with 144 additions and 52 deletions

View File

@@ -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<CrewAgent[]>(() => {
const matches = new Map<string, CrewAgent>();
const matches = new Map<string, CrewAgent & { publisherRun: boolean }>();
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 (
<div
@@ -130,37 +156,60 @@ export function PersonaCatalogPassport({
<p className="text-base font-semibold text-foreground">Passport</p>
{isResolving ? (
<Skeleton className="mt-3 h-14 w-64 rounded-xl" />
) : firstAgent ? (
) : (
<div className="mt-3 space-y-4">
<AgentCrewList
agents={deployedAgents}
onOpenPassport={openPassport}
/>
{badges.length > 0 ? (
<div className="space-y-2">
<p className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
{deployedAgents.length > 1
? `Commendations · ${firstAgent.name}`
: "Commendations"}
</p>
<PassportBadgeList badges={badges} />
</div>
{firstAgent ? (
<>
<div className="space-y-2">
<p className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
Running in this community
</p>
<AgentCrewList
agents={deployedAgents}
onOpenPassport={openPassport}
/>
</div>
{badges.length > 0 ? (
<div className="space-y-2">
<p className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
{deployedAgents.length > 1
? `Commendations · ${firstAgent.name}`
: "Commendations"}
</p>
<PassportBadgeList badges={badges} />
</div>
) : (
<p className="text-sm text-muted-foreground">
No commendations on record yet — badges appear as this agent
ships work.
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
No commendations on record yet — badges appear as this agent ships
work.
<p
className="text-sm text-muted-foreground"
data-testid="persona-catalog-passport-empty"
>
No agent is running this persona in this community right now. Its
record — merged pull requests, reviews, and commendations —
appears here once one is.
</p>
)}
{publisherPubkey ? (
<div>
<Button
className="text-muted-foreground hover:text-foreground"
data-testid="persona-catalog-publisher-passport"
onClick={() => openPassport(publisherPubkey)}
size="sm"
variant="ghost"
>
<IdCard className="mr-2 h-3.5 w-3.5" />
View {publisherLabel} passport
</Button>
</div>
) : null}
</div>
) : (
<p
className="mt-1 text-sm text-muted-foreground"
data-testid="persona-catalog-passport-empty"
>
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.
</p>
)}
</div>
);

View File

@@ -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