mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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 <thomasp@squareup.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 ? <PersonaCatalogDetailSkeleton /> : null}
|
||||
|
||||
{!isLoading && selectedPersona ? (
|
||||
<PersonaCatalogDetail persona={selectedPersona} />
|
||||
<PersonaCatalogDetail onClose={onClose} persona={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}
|
||||
/>
|
||||
|
||||
<PersonaCatalogPassport onBeforeNavigate={onClose} persona={persona} />
|
||||
|
||||
<div className="min-w-0 max-w-full pt-3">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Agent instruction
|
||||
|
||||
167
desktop/src/features/agents/ui/PersonaCatalogPassport.tsx
Normal file
167
desktop/src/features/agents/ui/PersonaCatalogPassport.tsx
Normal file
@@ -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<CrewAgent[]>(() => {
|
||||
const matches = new Map<string, CrewAgent>();
|
||||
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 (
|
||||
<div
|
||||
className="min-w-0 max-w-full pt-3"
|
||||
data-testid="persona-catalog-passport"
|
||||
>
|
||||
<p className="text-base font-semibold text-foreground">Passport</p>
|
||||
{isResolving ? (
|
||||
<Skeleton className="mt-3 h-14 w-64 rounded-xl" />
|
||||
) : firstAgent ? (
|
||||
<div className="mt-3 space-y-4">
|
||||
<AgentCrewList
|
||||
agents={deployedAgents}
|
||||
onOpenPassport={openPassport}
|
||||
/>
|
||||
{badges.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
|
||||
{deployedAgents.length > 1
|
||||
? `Commendations · ${firstAgent.name}`
|
||||
: "Commendations"}
|
||||
</p>
|
||||
<PassportBadgeList badges={badges} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No commendations on record yet — badges appear as this agent ships
|
||||
work.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="mt-1 text-sm text-muted-foreground"
|
||||
data-testid="persona-catalog-passport-empty"
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
desktop/src/features/passport/hooks/useAgentPassportBadges.ts
Normal file
134
desktop/src/features/passport/hooks/useAgentPassportBadges.ts
Normal file
@@ -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 };
|
||||
}
|
||||
123
desktop/src/features/passport/lib/craftSignals.test.mjs
Normal file
123
desktop/src/features/passport/lib/craftSignals.test.mjs
Normal file
@@ -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);
|
||||
});
|
||||
74
desktop/src/features/passport/lib/craftSignals.ts
Normal file
74
desktop/src/features/passport/lib/craftSignals.ts
Normal file
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
43
desktop/src/features/passport/lib/holderChannels.ts
Normal file
43
desktop/src/features/passport/lib/holderChannels.ts
Normal file
@@ -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<string, HolderChannelLink>();
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -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<string, { id: string; name: string }>();
|
||||
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<string>();
|
||||
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 =
|
||||
|
||||
127
desktop/tests/e2e/persona-catalog-passport.spec.ts
Normal file
127
desktop/tests/e2e/persona-catalog-passport.spec.ts
Normal file
@@ -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` });
|
||||
});
|
||||
Reference in New Issue
Block a user