From 1dae17b5927899e80d23dda5e9a65929ec75f90e Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 4 Aug 2026 16:30:45 +0200 Subject: [PATCH] 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 --- desktop/playwright.config.ts | 1 + .../agents/ui/PersonaCatalogDialog.tsx | 16 +- .../agents/ui/PersonaCatalogPassport.tsx | 167 ++++++++++++++++++ .../passport/hooks/useAgentPassportBadges.ts | 134 ++++++++++++++ .../passport/lib/craftSignals.test.mjs | 123 +++++++++++++ .../src/features/passport/lib/craftSignals.ts | 74 ++++++++ .../features/passport/lib/holderChannels.ts | 43 +++++ .../features/passport/ui/PassportScreen.tsx | 162 ++--------------- .../e2e/persona-catalog-passport.spec.ts | 127 +++++++++++++ 9 files changed, 696 insertions(+), 151 deletions(-) create mode 100644 desktop/src/features/agents/ui/PersonaCatalogPassport.tsx create mode 100644 desktop/src/features/passport/hooks/useAgentPassportBadges.ts create mode 100644 desktop/src/features/passport/lib/craftSignals.test.mjs create mode 100644 desktop/src/features/passport/lib/craftSignals.ts create mode 100644 desktop/src/features/passport/lib/holderChannels.ts create mode 100644 desktop/tests/e2e/persona-catalog-passport.spec.ts 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` }); +});