diff --git a/desktop/src/features/passport/ui/PassportRecordSections.tsx b/desktop/src/features/passport/ui/PassportRecordSections.tsx index 54521340ac..b49761a6d4 100644 --- a/desktop/src/features/passport/ui/PassportRecordSections.tsx +++ b/desktop/src/features/passport/ui/PassportRecordSections.tsx @@ -148,6 +148,53 @@ export function NoteRecordList({ notes }: { notes: UserNote[] }) { ); } +// ── Agent crew ─────────────────────────────────────────────────────────────── + +export type CrewAgent = { + avatarUrl: string | null; + name: string; + pubkey: string; +}; + +/** + * Agents operating under the holder's identity. Each chip opens that agent's + * own passport — the operator link on it points back here. + */ +export function AgentCrewList({ + agents, + onOpenPassport, +}: { + agents: CrewAgent[]; + onOpenPassport: (pubkey: string) => void; +}) { + return ( +
+ {agents.map((agent) => ( + + ))} +
+ ); +} + // ── Following strip ────────────────────────────────────────────────────────── export function FollowingStrip({ diff --git a/desktop/src/features/passport/ui/PassportScreen.tsx b/desktop/src/features/passport/ui/PassportScreen.tsx index f15ef452fa..fa8abb5d45 100644 --- a/desktop/src/features/passport/ui/PassportScreen.tsx +++ b/desktop/src/features/passport/ui/PassportScreen.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useNavigate } from "@tanstack/react-router"; import { Activity, Copy, UserRound } from "lucide-react"; import { toast } from "sonner"; @@ -19,6 +20,8 @@ import { normalizeRelayUrl, } from "@/features/passport/lib/travelLog"; import { + AgentCrewList, + type CrewAgent, EmptyRecord, FollowingStrip, formatStampDate, @@ -157,6 +160,63 @@ export function PassportScreen({ ); const contactProfilesQuery = useUsersBatchQuery(contactPubkeys.slice(0, 12)); + // Agents operating under the holder's identity. Relay agents declare their + // owner publicly (kind-0 ownerPubkey), so this works on anyone's passport; + // locally managed agents are the viewer's own custody and merge in only on + // the self passport. + const agentPubkeys = React.useMemo( + () => (relayAgentsQuery.data ?? []).map((agent) => agent.pubkey), + [relayAgentsQuery.data], + ); + const agentProfilesQuery = useUsersBatchQuery(agentPubkeys); + const ownedAgents = React.useMemo(() => { + if (!pubkeyLower || isAgent) { + return []; + } + const agents = new Map(); + for (const agent of relayAgentsQuery.data ?? []) { + const summary = + agentProfilesQuery.data?.profiles[agent.pubkey.toLowerCase()]; + if (summary?.ownerPubkey?.toLowerCase() === pubkeyLower) { + agents.set(agent.pubkey.toLowerCase(), { + avatarUrl: summary.avatarUrl ?? null, + name: summary.displayName?.trim() || agent.name, + pubkey: agent.pubkey, + }); + } + } + if (isSelf) { + for (const agent of managedAgentsQuery.data ?? []) { + const key = agent.pubkey.toLowerCase(); + if (!agents.has(key)) { + agents.set(key, { + avatarUrl: agent.avatarUrl, + name: agent.name, + pubkey: agent.pubkey, + }); + } + } + } + return [...agents.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); + }, [ + agentProfilesQuery.data, + isAgent, + isSelf, + managedAgentsQuery.data, + pubkeyLower, + relayAgentsQuery.data, + ]); + + const navigate = useNavigate(); + const openAgentPassport = React.useCallback( + (agentPubkey: string) => { + void navigate({ search: { pubkey: agentPubkey }, to: "/passport" }); + }, + [navigate], + ); + // Card fields, resolved the same way the passport dialog resolves them. const displayName = profile?.displayName?.trim() || @@ -184,6 +244,7 @@ export function PassportScreen({ memberChannels.length === 0 && notes.length === 0 && contactPubkeys.length === 0 && + ownedAgents.length === 0 && !isSelf && !hasAgentRecord; @@ -284,6 +345,18 @@ export function PassportScreen({ ) : null} + {ownedAgents.length > 0 ? ( + + + + ) : null} + {hasAgentRecord ? (