mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user