feat(desktop): list the holder's agents on the passport screen

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 <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-04 14:56:49 +02:00
parent 854f53380b
commit a40d552a5c
2 changed files with 120 additions and 0 deletions

View File

@@ -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 (
<div className="flex flex-wrap gap-2" data-testid="passport-agents">
{agents.map((agent) => (
<button
className="flex min-w-0 items-center gap-2.5 rounded-xl border border-border/70 bg-muted/40 py-2 pr-3.5 pl-2.5 text-left transition-colors hover:border-border hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
data-testid="passport-agent-chip"
key={agent.pubkey}
onClick={() => onOpenPassport(agent.pubkey)}
type="button"
>
<ProfileAvatar
avatarUrl={agent.avatarUrl}
className="h-8 w-8"
iconClassName="h-3.5 w-3.5"
label={agent.name}
/>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{agent.name}</span>
<span className="text-3xs font-medium tracking-[0.2em] text-muted-foreground uppercase">
Agent · View passport
</span>
</span>
</button>
))}
</div>
);
}
// ── Following strip ──────────────────────────────────────────────────────────
export function FollowingStrip({

View File

@@ -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<CrewAgent[]>(() => {
if (!pubkeyLower || isAgent) {
return [];
}
const agents = new Map<string, CrewAgent>();
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({
</RecordSection>
) : null}
{ownedAgents.length > 0 ? (
<RecordSection
label="Operates · Agents"
trailing={`${ownedAgents.length}`}
>
<AgentCrewList
agents={ownedAgents}
onOpenPassport={openAgentPassport}
/>
</RecordSection>
) : null}
{hasAgentRecord ? (
<RecordSection label="Agent record · Operator only">
<div className="flex flex-col gap-3">