From a40d552a5c78a0cc93540c802d848d10d68cd836 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 4 Aug 2026 14:56:49 +0200 Subject: [PATCH] feat(desktop): list the holder's agents on the passport screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Operates · Agents" section shows agents created under the holder's identity: relay agents that publicly declare the holder as owner (works on anyone's passport) merged with locally managed agents on your own. Each chip links to that agent's passport, whose operator field points back. Signed-off-by: Thomas Petersen --- .../passport/ui/PassportRecordSections.tsx | 47 ++++++++++++ .../features/passport/ui/PassportScreen.tsx | 73 +++++++++++++++++++ 2 files changed, 120 insertions(+) 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 ? (