diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index e561e502c7..c04b0eb3eb 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -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",
diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx
index e1ec946092..ae8a694250 100644
--- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx
+++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx
@@ -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 ? : null}
{!isLoading && 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}
/>
+
+
Agent instruction
diff --git a/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx b/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx
new file mode 100644
index 0000000000..8713504eb8
--- /dev/null
+++ b/desktop/src/features/agents/ui/PersonaCatalogPassport.tsx
@@ -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(() => {
+ const matches = new Map();
+ 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 (
+
+
Passport
+ {isResolving ? (
+
+ ) : firstAgent ? (
+
+
+ {badges.length > 0 ? (
+
+
+ {deployedAgents.length > 1
+ ? `Commendations · ${firstAgent.name}`
+ : "Commendations"}
+
+
+
+ ) : (
+
+ No commendations on record yet — badges appear as this agent ships
+ work.
+
+ )}
+
+ ) : (
+
+ 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/src/features/passport/hooks/useAgentPassportBadges.ts b/desktop/src/features/passport/hooks/useAgentPassportBadges.ts
new file mode 100644
index 0000000000..f2c2cbdac9
--- /dev/null
+++ b/desktop/src/features/passport/hooks/useAgentPassportBadges.ts
@@ -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 };
+}
diff --git a/desktop/src/features/passport/lib/craftSignals.test.mjs b/desktop/src/features/passport/lib/craftSignals.test.mjs
new file mode 100644
index 0000000000..05ea2f56f5
--- /dev/null
+++ b/desktop/src/features/passport/lib/craftSignals.test.mjs
@@ -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);
+});
diff --git a/desktop/src/features/passport/lib/craftSignals.ts b/desktop/src/features/passport/lib/craftSignals.ts
new file mode 100644
index 0000000000..ad028dd7d0
--- /dev/null
+++ b/desktop/src/features/passport/lib/craftSignals.ts
@@ -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();
+ 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;
+}
diff --git a/desktop/src/features/passport/lib/holderChannels.ts b/desktop/src/features/passport/lib/holderChannels.ts
new file mode 100644
index 0000000000..e011ca0ede
--- /dev/null
+++ b/desktop/src/features/passport/lib/holderChannels.ts
@@ -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();
+ 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),
+ );
+}
diff --git a/desktop/src/features/passport/ui/PassportScreen.tsx b/desktop/src/features/passport/ui/PassportScreen.tsx
index 45dd620a56..62e8d73061 100644
--- a/desktop/src/features/passport/ui/PassportScreen.tsx
+++ b/desktop/src/features/passport/ui/PassportScreen.tsx
@@ -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();
- 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();
- 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 =
diff --git a/desktop/tests/e2e/persona-catalog-passport.spec.ts b/desktop/tests/e2e/persona-catalog-passport.spec.ts
new file mode 100644
index 0000000000..246f248f39
--- /dev/null
+++ b/desktop/tests/e2e/persona-catalog-passport.spec.ts
@@ -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` });
+});