feat(desktop): show the deployed agent's passport record in the agent catalog

Each catalog entry now carries a Passport section: the live agents running
that persona in the community (viewer's managed agents link by persona id;
other members' agents match on declared operator + persona name pool), the
first agent's commendations, and a chip that jumps to its full passport.
Badge computation moves into a shared useAgentPassportBadges hook with the
craft counting and channel-membership resolution extracted into pure,
tested lib functions, so the passport screen and the catalog read the exact
same record.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-04 16:30:45 +02:00
parent 0504517e21
commit 1dae17b592
9 changed files with 696 additions and 151 deletions

View File

@@ -134,6 +134,7 @@ export default defineConfig({
"**/observer-archive-policy.spec.ts",
"**/harness-management.spec.ts",
"**/harness-catalog-screenshots.spec.ts",
"**/persona-catalog-passport.spec.ts",
"**/inline-custom-harness.spec.ts",
"**/where-to-run-config.spec.ts",
"**/huddle-transcription.spec.ts",

View File

@@ -16,6 +16,7 @@ import { Skeleton } from "@/shared/ui/skeleton";
import agentOutlineUrl from "../assets/agent-outline.svg";
import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
import { PersonaAddedBy } from "./PersonaAddedBy";
import { PersonaCatalogPassport } from "./PersonaCatalogPassport";
import { personaCatalogCopy } from "./personaLibraryCopy";
type PersonaCatalogDialogProps = {
@@ -124,6 +125,7 @@ export function PersonaCatalogDialog({
isLoading={isLoading}
isPending={isPending}
isSelectedPersonaActive={selectedPersonaIsActive}
onClose={() => onOpenChange(false)}
onUsePersona={handleUseSelectedPersona}
onSelectPersona={setSelectedPersonaId}
personas={personas}
@@ -140,6 +142,7 @@ type PersonaCatalogChooserProps = {
isLoading: boolean;
isPending: boolean;
isSelectedPersonaActive: boolean;
onClose: () => void;
onUsePersona: () => void;
onSelectPersona: (personaId: string) => void;
personas: AgentPersona[];
@@ -152,6 +155,7 @@ function PersonaCatalogChooser({
isLoading,
isPending,
isSelectedPersonaActive,
onClose,
onUsePersona,
onSelectPersona,
personas,
@@ -237,7 +241,7 @@ function PersonaCatalogChooser({
{isLoading ? <PersonaCatalogDetailSkeleton /> : null}
{!isLoading && selectedPersona ? (
<PersonaCatalogDetail persona={selectedPersona} />
<PersonaCatalogDetail onClose={onClose} persona={selectedPersona} />
) : null}
{error ? (
@@ -293,7 +297,13 @@ export function resolveCatalogOwnerLabel(
);
}
function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
function PersonaCatalogDetail({
onClose,
persona,
}: {
onClose: () => void;
persona: AgentPersona;
}) {
const isCommunityEntry =
isCatalogPersona(persona) && !persona.catalogSource.isOwn;
const ownerPubkey = isCommunityEntry
@@ -337,6 +347,8 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
runtime={persona.runtime}
/>
<PersonaCatalogPassport onBeforeNavigate={onClose} persona={persona} />
<div className="min-w-0 max-w-full pt-3">
<p className="text-base font-semibold text-foreground">
Agent instruction

View File

@@ -0,0 +1,167 @@
import * as React from "react";
import { useNavigate } from "@tanstack/react-router";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useAgentPassportBadges } from "@/features/passport/hooks/useAgentPassportBadges";
import { PassportBadgeList } from "@/features/passport/ui/PassportBadges";
import {
AgentCrewList,
type CrewAgent,
} from "@/features/passport/ui/PassportRecordSections";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { AgentPersona } from "@/shared/api/types";
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.
*
* 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.
*/
export function PersonaCatalogPassport({
onBeforeNavigate,
persona,
}: {
onBeforeNavigate: () => void;
persona: AgentPersona;
}) {
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.
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 agentProfilesQuery = useUsersBatchQuery(agentPubkeys);
const deployedAgents = React.useMemo<CrewAgent[]>(() => {
const matches = new Map<string, CrewAgent>();
const candidateNames = new Set(
[persona.displayName, ...persona.namePool]
.map((name) => name.trim().toLowerCase())
.filter((name) => name.length > 0),
);
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 displayName = summary?.displayName?.trim() || agent.name;
if (
candidateNames.has(displayName.toLowerCase()) ||
candidateNames.has(agent.name.trim().toLowerCase())
) {
matches.set(agent.pubkey.toLowerCase(), {
avatarUrl: summary?.avatarUrl ?? null,
name: displayName,
pubkey: agent.pubkey,
});
}
}
// The viewer's own managed agents carry an explicit persona link — no
// name heuristic needed.
for (const agent of managedAgentsQuery.data ?? []) {
if (agent.personaId !== persona.id) {
continue;
}
const key = agent.pubkey.toLowerCase();
if (!matches.has(key)) {
matches.set(key, {
avatarUrl: agent.avatarUrl,
name: agent.name,
pubkey: agent.pubkey,
});
}
}
return [...matches.values()].sort((left, right) =>
left.name.localeCompare(right.name),
);
}, [
agentProfilesQuery.data,
managedAgentsQuery.data,
persona.displayName,
persona.id,
persona.namePool,
publisherPubkey,
relayAgentsQuery.data,
]);
const firstAgent = deployedAgents[0] ?? null;
const { badges } = useAgentPassportBadges(firstAgent?.pubkey ?? null);
const navigate = useNavigate();
const openPassport = React.useCallback(
(pubkey: string) => {
onBeforeNavigate();
void navigate({ search: { pubkey }, to: "/passport" });
},
[navigate, onBeforeNavigate],
);
const isResolving =
relayAgentsQuery.isLoading || agentProfilesQuery.isLoading;
return (
<div
className="min-w-0 max-w-full pt-3"
data-testid="persona-catalog-passport"
>
<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>
) : (
<p className="text-sm text-muted-foreground">
No commendations on record yet — badges appear as this agent ships
work.
</p>
)}
</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

@@ -0,0 +1,134 @@
import * as React from "react";
import {
useAgentMemoryQuery,
useIsManagedAgent,
} from "@/features/agent-memory/hooks";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import { useRelayAgentsQuery } from "@/features/agents/hooks";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
computeAgentBadges,
type PassportBadge,
} from "@/features/passport/lib/badges";
import { countCraftSignals } from "@/features/passport/lib/craftSignals";
import { resolveHolderChannels } from "@/features/passport/lib/holderChannels";
import { useUserProfileQuery } from "@/features/profile/hooks";
import {
useProjectsQuery,
useProjectsWorkItemsQuery,
} from "@/features/projects/hooks";
import {
useMyNotesQuery,
usePulseReactionsQuery,
} from "@/features/pulse/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
export type AgentPassportBadges = {
badges: PassportBadge[];
/** Engram count; null when the viewer cannot see the agent's memories. */
memoryCount: number | null;
/** Whether the current identity operates this agent. */
viewerIsOwner: boolean;
};
/**
* The commendations an agent has earned, computed from the same verifiable
* record the passport screen shows: profile provenance, signed NIP-34 git
* events (craft), authored notes, reactions received, channel memberships,
* and owner-visible memories. Pass `null` to disable (non-agent holders).
*/
export function useAgentPassportBadges(
pubkey: string | null,
): AgentPassportBadges {
const pubkeyLower = pubkey?.toLowerCase() ?? null;
const identityQuery = useIdentityQuery();
const selfPubkey = identityQuery.data?.pubkey ?? null;
const profileQuery = useUserProfileQuery(pubkey ?? undefined);
const profile = profileQuery.data;
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgent = relayAgentsQuery.data?.find(
(agent) => agent.pubkey.toLowerCase() === pubkeyLower,
);
const channelsQuery = useChannelsQuery();
const memberChannels = React.useMemo(
() => resolveHolderChannels(channelsQuery.data, relayAgent, pubkeyLower),
[channelsQuery.data, pubkeyLower, relayAgent],
);
const isLocallyManaged = useIsManagedAgent(pubkey);
const viewerIsOwner =
pubkey !== null &&
(isLocallyManaged === true ||
(profile?.ownerPubkey != null &&
profile.ownerPubkey.toLowerCase() === selfPubkey?.toLowerCase()));
const memoryQuery = useAgentMemoryQuery(pubkey, { enabled: viewerIsOwner });
const memoryCount = memoryQuery.data
? memoryQuery.data.memories.length + (memoryQuery.data.core ? 1 : 0)
: null;
const notesQuery = useMyNotesQuery(pubkey ?? undefined);
const allNotes = React.useMemo(
() => notesQuery.data?.notes ?? [],
[notesQuery.data],
);
const noteIds = React.useMemo(
() => allNotes.map((note) => note.id),
[allNotes],
);
const reactionsQuery = usePulseReactionsQuery(pubkey ? noteIds : []);
const activeTurnCount = useAgentWorking(pubkey).channels.length;
// Craft signals: the agent's engineering record, read from signed NIP-34
// git events across every project in the community. Only enabled callers
// pay for the work-items fetch.
const projectsQuery = useProjectsQuery();
const workItemsProjects = React.useMemo(
() => (pubkey ? (projectsQuery.data ?? []) : []),
[projectsQuery.data, pubkey],
);
const workItemsQuery = useProjectsWorkItemsQuery(workItemsProjects);
const craft = React.useMemo(
() => countCraftSignals(workItemsQuery.data, pubkeyLower),
[pubkeyLower, workItemsQuery.data],
);
const badges = React.useMemo(() => {
if (!pubkey) {
return [];
}
let reactionCount = 0;
for (const state of reactionsQuery.data?.values() ?? []) {
reactionCount += state.count;
}
return computeAgentBadges({
...craft,
activeTurnCount,
channelCount: memberChannels.length,
firstSeenAt:
allNotes.length > 0
? Math.min(...allNotes.map((note) => note.createdAt))
: null,
hasAbout: Boolean(profile?.about?.trim()),
hasAvatar: Boolean(profile?.avatarUrl),
hasHandle: Boolean(profile?.nip05Handle?.trim()),
hasName: Boolean(profile?.displayName?.trim()),
hasOwner: profile?.ownerPubkey != null,
memoryCount,
noteCount: allNotes.length,
reactionCount,
});
}, [
activeTurnCount,
allNotes,
craft,
memberChannels.length,
memoryCount,
profile,
pubkey,
reactionsQuery.data,
]);
return { badges, memoryCount, viewerIsOwner };
}

View File

@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import { countCraftSignals } from "./craftSignals.ts";
const AGENT = "a".repeat(64);
const OTHER = "b".repeat(64);
function pullRequest(overrides = {}) {
return {
author: AGENT,
comments: [],
status: "Open",
...overrides,
};
}
function comment(overrides = {}) {
return {
author: AGENT,
isApproval: false,
isChangeRequest: false,
isInlineComment: false,
...overrides,
};
}
function workItems({ issues = [], pullRequests = [] } = {}) {
return {
issues: { items: issues },
pullRequests: { items: pullRequests },
};
}
test("missing data or pubkey yields zero counts", () => {
const zero = countCraftSignals(undefined, AGENT);
assert.deepEqual(zero, {
closedPrCount: 0,
issuesOpenedCount: 0,
mergedPrCount: 0,
repoCount: 0,
reviewCount: 0,
});
assert.deepEqual(countCraftSignals(workItems(), null), zero);
});
test("authored PRs split into merged and closed, keyed case-insensitively", () => {
const counts = countCraftSignals(
workItems({
pullRequests: [
{
project: { id: "repo-1" },
pullRequest: pullRequest({
author: AGENT.toUpperCase(),
status: "Merged",
}),
},
{
project: { id: "repo-1" },
pullRequest: pullRequest({ status: "Closed" }),
},
{
project: { id: "repo-1" },
pullRequest: pullRequest({ status: "Open" }),
},
{
project: { id: "repo-2" },
pullRequest: pullRequest({ author: OTHER, status: "Merged" }),
},
],
}),
AGENT,
);
assert.equal(counts.mergedPrCount, 1);
assert.equal(counts.closedPrCount, 1);
assert.equal(counts.repoCount, 1);
});
test("only review-weight comments on others' PRs count as reviews", () => {
const counts = countCraftSignals(
workItems({
pullRequests: [
{
project: { id: "repo-2" },
pullRequest: pullRequest({
author: OTHER,
comments: [
comment({ isApproval: true }),
comment({ isInlineComment: true }),
comment(), // plain discussion — no review weight
comment({ author: OTHER, isApproval: true }), // not the agent
],
}),
},
{
// Comments on the agent's own PR never count as reviews.
project: { id: "repo-1" },
pullRequest: pullRequest({
comments: [comment({ isApproval: true })],
}),
},
],
}),
AGENT,
);
assert.equal(counts.reviewCount, 2);
assert.equal(counts.repoCount, 2);
});
test("filed issues count and extend repo spread", () => {
const counts = countCraftSignals(
workItems({
issues: [
{ project: { id: "repo-3" }, issue: { author: AGENT } },
{ project: { id: "repo-3" }, issue: { author: AGENT } },
{ project: { id: "repo-4" }, issue: { author: OTHER } },
],
}),
AGENT,
);
assert.equal(counts.issuesOpenedCount, 2);
assert.equal(counts.repoCount, 1);
});

View File

@@ -0,0 +1,74 @@
/**
* Craft signal counting — turns the community's NIP-34 work items into the
* per-pubkey engineering counts that feed `computeAgentBadges`. Shared by the
* passport screen and the agent catalog so both surfaces read the exact same
* record.
*/
import type { ProjectIssue } from "@/features/projects/projectIssues.mjs";
import type { ProjectPullRequest } from "@/features/projects/projectPullRequests.mjs";
export type CraftSignalCounts = {
closedPrCount: number;
issuesOpenedCount: number;
mergedPrCount: number;
repoCount: number;
reviewCount: number;
};
type CraftWorkItems = {
issues: { items: Array<{ project: { id: string }; issue: ProjectIssue }> };
pullRequests: {
items: Array<{ project: { id: string }; pullRequest: ProjectPullRequest }>;
};
};
/** Count the pubkey's authored PRs, filed issues, reviews, and repo spread. */
export function countCraftSignals(
workItems: CraftWorkItems | undefined,
pubkeyLower: string | null,
): CraftSignalCounts {
const counts: CraftSignalCounts = {
closedPrCount: 0,
issuesOpenedCount: 0,
mergedPrCount: 0,
repoCount: 0,
reviewCount: 0,
};
if (!pubkeyLower || !workItems) {
return counts;
}
const repos = new Set<string>();
for (const { project, pullRequest } of workItems.pullRequests.items) {
if (pullRequest.author.toLowerCase() === pubkeyLower) {
repos.add(project.id);
if (pullRequest.status === "Merged") {
counts.mergedPrCount += 1;
} else if (pullRequest.status === "Closed") {
counts.closedPrCount += 1;
}
continue;
}
// Reviews only count on other authors' PRs, and only comments that carry
// review weight (decisions and inline code comments).
for (const comment of pullRequest.comments) {
if (
comment.author.toLowerCase() === pubkeyLower &&
(comment.isApproval ||
comment.isChangeRequest ||
comment.isInlineComment)
) {
counts.reviewCount += 1;
repos.add(project.id);
}
}
}
for (const { issue, project } of workItems.issues.items) {
if (issue.author.toLowerCase() === pubkeyLower) {
counts.issuesOpenedCount += 1;
repos.add(project.id);
}
}
counts.repoCount = repos.size;
return counts;
}

View File

@@ -0,0 +1,43 @@
/**
* Channel membership resolution for a passport holder, shared by the passport
* screen and the agent-badge hook. Membership is derived from the viewer's
* own channel list, so private rooms only ever appear to viewers already in
* them; relay agents additionally declare their working channels.
*/
export type HolderChannelLink = { id: string; name: string };
export function resolveHolderChannels(
channels:
| readonly {
channelType: string;
id: string;
memberPubkeys: readonly string[];
name: string;
}[]
| undefined,
relayAgent:
| { channelIds: readonly string[]; channels: readonly string[] }
| undefined,
pubkeyLower: string | null,
): HolderChannelLink[] {
const links = new Map<string, HolderChannelLink>();
relayAgent?.channels.forEach((name, index) => {
const id = relayAgent.channelIds[index] ?? name;
links.set(id, { id, name });
});
for (const channel of channels ?? []) {
if (channel.channelType === "dm") {
continue;
}
const isMember = channel.memberPubkeys.some(
(memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower,
);
if (isMember) {
links.set(channel.id, { id: channel.id, name: channel.name });
}
}
return [...links.values()].sort((left, right) =>
left.name.localeCompare(right.name),
);
}

View File

@@ -4,11 +4,6 @@ import { Activity, Copy, UserRound } from "lucide-react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
useAgentMemoryQuery,
useIsManagedAgent,
} from "@/features/agent-memory/hooks";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
@@ -16,7 +11,8 @@ import {
import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
import { computeAgentBadges } from "@/features/passport/lib/badges";
import { useAgentPassportBadges } from "@/features/passport/hooks/useAgentPassportBadges";
import { resolveHolderChannels } from "@/features/passport/lib/holderChannels";
import {
loadTravelLog,
normalizeRelayUrl,
@@ -33,20 +29,13 @@ import {
RecordSection,
StampPage,
} from "@/features/passport/ui/PassportRecordSections";
import {
useProjectsQuery,
useProjectsWorkItemsQuery,
} from "@/features/projects/hooks";
import {
useContactListQuery,
useUserProfileQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { PassportCard } from "@/features/profile/ui/PassportCard";
import {
useMyNotesQuery,
usePulseReactionsQuery,
} from "@/features/pulse/hooks";
import { useMyNotesQuery } from "@/features/pulse/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { safeNpub } from "@/shared/lib/nostrUtils";
@@ -98,12 +87,12 @@ export function PassportScreen({
managedAgent ??
(summary?.isAgent || profile?.ownerPubkey != null),
);
const isLocallyManaged = useIsManagedAgent(isAgent ? pubkey : null);
const viewerIsOwner =
isAgent &&
(isLocallyManaged === true ||
(profile?.ownerPubkey != null &&
profile.ownerPubkey.toLowerCase() === selfPubkey?.toLowerCase()));
// Badges, memory visibility, and ownership come from the shared hook so the
// passport screen and the agent catalog read the exact same record.
const { badges, memoryCount, viewerIsOwner } = useAgentPassportBadges(
isAgent ? (pubkey ?? null) : null,
);
// Record sources. Channel membership is derived from the viewer's channel
// list, so private rooms only ever appear to viewers already in them.
@@ -111,31 +100,13 @@ export function PassportScreen({
const channelsQuery = useChannelsQuery();
const notesQuery = useMyNotesQuery(pubkey ?? undefined);
const contactsQuery = useContactListQuery(pubkey ?? undefined);
const memoryQuery = useAgentMemoryQuery(pubkey, { enabled: viewerIsOwner });
const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity();
const { goChannel } = useAppNavigation();
const memberChannels = React.useMemo(() => {
const links = new Map<string, { id: string; name: string }>();
relayAgent?.channels.forEach((name, index) => {
const id = relayAgent.channelIds[index] ?? name;
links.set(id, { id, name });
});
for (const channel of channelsQuery.data ?? []) {
if (channel.channelType === "dm") {
continue;
}
const isMember = channel.memberPubkeys.some(
(memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower,
);
if (isMember) {
links.set(channel.id, { id: channel.id, name: channel.name });
}
}
return [...links.values()].sort((left, right) =>
left.name.localeCompare(right.name),
);
}, [channelsQuery.data, pubkeyLower, relayAgent]);
const memberChannels = React.useMemo(
() => resolveHolderChannels(channelsQuery.data, relayAgent, pubkeyLower),
[channelsQuery.data, pubkeyLower, relayAgent],
);
// Visas: servers you are on now (saved communities) plus servers you have
// been on before (persistent travel log), shown as expired stamps. Both are
@@ -242,113 +213,6 @@ export function PassportScreen({
const npub = pubkey ? (safeNpub(pubkey) ?? pubkey) : null;
const notes = notesQuery.data?.notes.slice(0, RECENT_NOTES_SHOWN) ?? [];
const memoryCount = memoryQuery.data
? memoryQuery.data.memories.length + (memoryQuery.data.core ? 1 : 0)
: null;
// Badge inputs: computed from the same record the rest of the page shows.
const allNotes = React.useMemo(
() => notesQuery.data?.notes ?? [],
[notesQuery.data],
);
const noteIds = React.useMemo(
() => allNotes.map((note) => note.id),
[allNotes],
);
const reactionsQuery = usePulseReactionsQuery(isAgent ? noteIds : []);
const activeTurnCount = useAgentWorking(isAgent ? pubkey : null).channels
.length;
// Craft signals: the agent's engineering record, read from signed NIP-34
// git events across every project in the community. Only agent passports
// pay for the work-items fetch.
const projectsQuery = useProjectsQuery();
const workItemsProjects = React.useMemo(
() => (isAgent ? (projectsQuery.data ?? []) : []),
[isAgent, projectsQuery.data],
);
const workItemsQuery = useProjectsWorkItemsQuery(workItemsProjects);
const craft = React.useMemo(() => {
const counts = {
closedPrCount: 0,
issuesOpenedCount: 0,
mergedPrCount: 0,
repoCount: 0,
reviewCount: 0,
};
const workItems = workItemsQuery.data;
if (!pubkeyLower || !workItems) {
return counts;
}
const repos = new Set<string>();
for (const { project, pullRequest } of workItems.pullRequests.items) {
if (pullRequest.author.toLowerCase() === pubkeyLower) {
repos.add(project.id);
if (pullRequest.status === "Merged") {
counts.mergedPrCount += 1;
} else if (pullRequest.status === "Closed") {
counts.closedPrCount += 1;
}
continue;
}
// Reviews only count on other authors' PRs, and only comments that
// carry review weight (decisions and inline code comments).
for (const comment of pullRequest.comments) {
if (
comment.author.toLowerCase() === pubkeyLower &&
(comment.isApproval ||
comment.isChangeRequest ||
comment.isInlineComment)
) {
counts.reviewCount += 1;
repos.add(project.id);
}
}
}
for (const { issue, project } of workItems.issues.items) {
if (issue.author.toLowerCase() === pubkeyLower) {
counts.issuesOpenedCount += 1;
repos.add(project.id);
}
}
counts.repoCount = repos.size;
return counts;
}, [pubkeyLower, workItemsQuery.data]);
const badges = React.useMemo(() => {
if (!isAgent) {
return [];
}
let reactionCount = 0;
for (const state of reactionsQuery.data?.values() ?? []) {
reactionCount += state.count;
}
return computeAgentBadges({
...craft,
activeTurnCount,
channelCount: memberChannels.length,
firstSeenAt:
allNotes.length > 0
? Math.min(...allNotes.map((note) => note.createdAt))
: null,
hasAbout: Boolean(profile?.about?.trim()),
hasAvatar: Boolean(profile?.avatarUrl),
hasHandle: Boolean(profile?.nip05Handle?.trim()),
hasName: Boolean(profile?.displayName?.trim()),
hasOwner: profile?.ownerPubkey != null,
memoryCount,
noteCount: allNotes.length,
reactionCount,
});
}, [
activeTurnCount,
allNotes,
craft,
isAgent,
memberChannels.length,
memoryCount,
profile,
reactionsQuery.data,
]);
const recordLoading =
channelsQuery.isLoading || notesQuery.isLoading || contactsQuery.isLoading;
const hasAgentRecord =

View File

@@ -0,0 +1,127 @@
import { expect, test } from "@playwright/test";
import type { RelayEvent } from "@/shared/api/types";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
// The owned relay agent fixture ("nadia") — profile declares the mock viewer
// as operator and carries the seeded NIP-34 engineering record.
const OWNED_RELAY_AGENT_PUBKEY =
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
const OUTDIR = "test-results/persona-catalog-passport";
function createCatalogEvent(input: {
id: string;
ownerPubkey: string;
sourcePersonaId: string;
displayName: string;
systemPrompt: string;
}): RelayEvent {
return {
id: input.id,
pubkey: input.ownerPubkey,
created_at: 1_721_750_400,
kind: 30175,
tags: [
["d", input.sourcePersonaId],
["shared", "true"],
],
content: JSON.stringify({
display_name: input.displayName,
system_prompt: input.systemPrompt,
avatar_url: null,
runtime: null,
model: null,
provider: null,
name_pool: [],
}),
sig: "2".repeat(128),
};
}
async function openCatalog(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page
.getByRole("menuitem", { exact: true, name: "Discover agents" })
.click();
}
test("catalog entry shows the deployed agent's passport record", async ({
page,
}) => {
// "nadia" is the mock relay agent owned by the viewer, with a seeded NIP-34
// engineering record — publishing a same-named persona under the viewer's
// key makes the catalog resolve that agent as its deployment.
await installMockBridge(page, {
personaCatalogEvents: [
createCatalogEvent({
displayName: "nadia",
id: "3".repeat(64),
ownerPubkey: DEFAULT_MOCK_PUBKEY,
sourcePersonaId: "nadia-persona",
systemPrompt: "Ship relay fixes and review incoming pull requests.",
}),
],
relayAgents: [
{
channelNames: ["agents"],
name: "nadia",
pubkey: OWNED_RELAY_AGENT_PUBKEY,
},
],
});
await openCatalog(page);
const passportSection = page.getByTestId("persona-catalog-passport");
await expect(passportSection).toBeVisible();
await expect(
passportSection.getByTestId("passport-agent-chip"),
).toBeVisible();
// Craft badges come from the seeded git record (merged PRs, issues,
// reviews) — their presence proves the record is wired through.
await expect(
passportSection.getByTestId("passport-badge-shipped"),
).toBeVisible({ timeout: 10_000 });
await expect(
passportSection.getByTestId("passport-badge-high-signal"),
).toBeVisible();
await waitForAnimations(page);
await page
.getByTestId("persona-catalog-detail-pane")
.screenshot({ path: `${OUTDIR}/01-catalog-passport.png` });
// The chip closes the dialog and lands on the agent's full passport.
await passportSection.getByTestId("passport-agent-chip").click();
await expect(page.getByTestId("persona-catalog-dialog")).toHaveCount(0);
await expect(page.getByTestId("passport-screen")).toBeVisible();
});
test("entry without a deployed agent shows the empty passport state", async ({
page,
}) => {
await installMockBridge(page, {
personaCatalogEvents: [
createCatalogEvent({
displayName: "Aloe",
id: "4".repeat(64),
ownerPubkey: TEST_IDENTITIES.alice.pubkey,
sourcePersonaId: "aloe-persona",
systemPrompt: "A persona nobody is running yet.",
}),
],
});
await openCatalog(page);
const emptyState = page.getByTestId("persona-catalog-passport-empty");
await expect(emptyState).toBeVisible();
await waitForAnimations(page);
await page
.getByTestId("persona-catalog-detail-pane")
.screenshot({ path: `${OUTDIR}/02-catalog-passport-empty.png` });
});