mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user