diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index b0668616aeb..ee11c927397 100644 --- a/desktop/src/features/agents/ui/AgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -11,6 +11,7 @@ type AgentIdentityCardProps = { avatarUrl?: string | null; dataTestId: string; label: string; + identityLabel?: string | null; modelLabel?: string | null; onClick: () => void; /** Optional badge rendered below the label (e.g. "Restart required"). */ @@ -24,6 +25,7 @@ export function AgentIdentityCard({ avatarUrl, dataTestId, label, + identityLabel, modelLabel, onClick, statusBadge, @@ -72,6 +74,11 @@ export function AgentIdentityCard({ {label} + {identityLabel ? ( + + {identityLabel} + + ) : null} {modelLabel ? ( {modelLabel} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index d0ff2e2738a..5811c43fded 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -8,19 +8,22 @@ import { import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; +import { truncatePubkey } from "@/shared/lib/pubkey"; import { Badge } from "@/shared/ui/badge"; import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { AgentIdentityCard } from "./AgentIdentityCard"; import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { PersonaActionsMenu } from "./PersonaActionsMenu"; -import { buildUnifiedGroups } from "./unifiedAgentGroups"; +import { + buildUnifiedGroups, + profileAgentsForGroup, +} from "./unifiedAgentGroups"; type UnifiedAgentsSectionProps = { defaultModel: string; @@ -130,41 +133,99 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { disabled={isPersonasPending} onClick={onOpenCatalog} /> - {groups.map((group) => { - const profileAgent = pickProfileAgent(group.agents, isArchived); - return ( - ( - - onSharePersona(persona, linkedAgent, effectiveAvatarUrl) - } - /> - )} - agent={profileAgent} - defaultModel={defaultModel} - key={group.persona.id} - persona={group.persona} - restartingAgentPubkey={restartingAgentPubkey} - startingAgentPubkey={startingAgentPubkey} - startingPersonaIds={startingPersonaIds} - onOpenAgentProfile={onOpenAgentProfile} - onOpenPersonaProfile={onOpenPersonaProfile} - onRestartAgent={onRestartAgent} - onStartAgent={onStartAgent} - onStartPersona={onStartPersona} - /> + {groups.flatMap((group) => { + const profileAgents = profileAgentsForGroup( + group.agents, + isArchived, ); + const cards: Array = []; + + if (profileAgents.length === 0) { + cards.push( + ( + + onSharePersona( + persona, + linkedAgent, + effectiveAvatarUrl, + ) + } + /> + )} + agent={undefined} + defaultModel={defaultModel} + key={`persona:${group.persona.id}`} + persona={group.persona} + restartingAgentPubkey={restartingAgentPubkey} + startingAgentPubkey={startingAgentPubkey} + startingPersonaIds={startingPersonaIds} + onOpenAgentProfile={onOpenAgentProfile} + onOpenPersonaProfile={onOpenPersonaProfile} + onRestartAgent={onRestartAgent} + onStartAgent={onStartAgent} + onStartPersona={onStartPersona} + />, + ); + return cards; + } + + for (const [index, profileAgent] of profileAgents.entries()) { + cards.push( + ( + + onSharePersona( + persona, + linkedAgent, + effectiveAvatarUrl, + ) + } + /> + ) + : undefined + } + agent={profileAgent} + defaultModel={defaultModel} + key={`agent:${profileAgent.pubkey}`} + persona={group.persona} + restartingAgentPubkey={restartingAgentPubkey} + startingAgentPubkey={startingAgentPubkey} + startingPersonaIds={startingPersonaIds} + onOpenAgentProfile={onOpenAgentProfile} + onOpenPersonaProfile={onOpenPersonaProfile} + onRestartAgent={onRestartAgent} + onStartAgent={onStartAgent} + onStartPersona={onStartPersona} + />, + ); + } + + return cards; })} @@ -256,7 +317,6 @@ function AgentPersonaCard({ const modelLabel = resolveAgentCardModelLabel({ agent, personaModel: persona.model, - provider: persona.provider, defaultModel, }); const isActive = agent ? isManagedAgentActive(agent) : false; @@ -310,19 +370,23 @@ function AgentPersonaCard({ ) } avatarUrl={avatarUrl} - dataTestId={`persona-agent-row-${persona.id}`} + dataTestId={ + agent + ? `managed-agent-${agent.pubkey}` + : `persona-agent-row-${persona.id}` + } + identityLabel={agent ? truncatePubkey(agent.pubkey) : null} label={title} modelLabel={modelLabel} onClick={() => { // The card's main click always opens the PERSONA target, never an // explicit pubkey. A pubkey target is durable in the panel, so a pick // made during the archive-snapshot fail-open window would strand the - // panel on an archived identity after hydration (Carl's cold-hydration - // race). A persona target re-resolves every render through the shared - // archive-aware selector, so it self-corrects to a live sibling — or - // persona-only mode when every instance is archived. Deliberate + // panel on an archived identity after hydration. A persona target + // re-resolves every render through the archive-aware selector. The + // per-instance card remains visually and test-id distinct; deliberate // instance navigation and the runtime-error affordance keep their - // explicit-pubkey path via the avatar control below. + // explicit-pubkey path via the avatar control above. onOpenPersonaProfile(persona); }} statusBadge={ @@ -397,7 +461,6 @@ function StandaloneAgentCard({ modelLabel={resolveAgentCardModelLabel({ agent, personaModel: null, - provider: agent.provider, defaultModel, })} onClick={() => { diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs index 690a921040e..0ceda2bbc08 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs +++ b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs @@ -220,7 +220,9 @@ test("persona card main click records a persona target, never an explicit pubkey }); fireEvent.click( - screen.getByRole("button", { name: "Fizz Prime agent profile" }), + screen.getAllByRole("button", { + name: "Fizz Prime agent profile", + })[0], ); assert.ok(recordedPersona, "the click must record a persona target"); diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs index b3ade7f229b..7dfa571cb72 100644 --- a/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs @@ -1,75 +1,132 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildUnifiedGroups } from "./unifiedAgentGroups.ts"; +import { + buildUnifiedGroups, + profileAgentsForGroup, +} from "./unifiedAgentGroups.ts"; -const NONE_ARCHIVED = () => false; - -function agent(overrides = {}) { +function agent(pubkey, overrides = {}) { return { - name: "Agent", - pubkey: "a".repeat(64), - personaId: null, - status: "stopped", + pubkey, + name: overrides.name ?? "Fizz", + personaId: overrides.personaId ?? "builtin:fizz", + status: overrides.status ?? "stopped", ...overrides, }; } -function persona(overrides = {}) { - return { id: "persona-1", displayName: "Persona", ...overrides }; -} +const fizz = { id: "builtin:fizz", displayName: "Fizz" }; +const NONE_ARCHIVED = () => false; + +test("buildUnifiedGroups retains every managed instance for one persona", () => { + const first = agent("a".repeat(64)); + const second = agent("b".repeat(64)); + + const { groups, ungrouped, unknown } = buildUnifiedGroups( + [fizz], + [first, second], + NONE_ARCHIVED, + ); + + assert.deepEqual(groups, [{ persona: fizz, agents: [first, second] }]); + assert.deepEqual(ungrouped, []); + assert.deepEqual(unknown, []); +}); + +test("profileAgentsForGroup returns every instance in stable order without mutating input", () => { + const stopped = agent("a".repeat(64), { name: "Zulu" }); + const runningLater = agent("c".repeat(64), { + name: "Alpha", + status: "running", + }); + const runningEarlier = agent("b".repeat(64), { + name: "Alpha", + status: "running", + }); + const input = [stopped, runningLater, runningEarlier]; + + assert.deepEqual(profileAgentsForGroup(input, NONE_ARCHIVED), [ + runningEarlier, + runningLater, + stopped, + ]); + assert.deepEqual(input, [stopped, runningLater, runningEarlier]); +}); + +test("a relay-restored persona instance follows the same visible group path", () => { + const relayRestored = agent("c".repeat(64), { + name: "Recovered Fizz", + status: "stopped", + }); + + const { groups } = buildUnifiedGroups([fizz], [relayRestored], NONE_ARCHIVED); + + assert.deepEqual(profileAgentsForGroup(groups[0].agents, NONE_ARCHIVED), [ + relayRestored, + ]); +}); + +test("a persona with no managed instance remains an empty group", () => { + const { groups } = buildUnifiedGroups([fizz], [], NONE_ARCHIVED); + + assert.deepEqual(groups, [{ persona: fizz, agents: [] }]); + assert.deepEqual(profileAgentsForGroup(groups[0].agents, NONE_ARCHIVED), []); +}); + +test("profileAgentsForGroup omits archived instances while preserving visible peers", () => { + const archived = agent("a".repeat(64), { status: "running" }); + const visible = agent("b".repeat(64), { status: "stopped" }); + + assert.deepEqual( + profileAgentsForGroup( + [archived, visible], + (pubkey) => pubkey === archived.pubkey, + ), + [visible], + ); +}); test("archived standalone custom agents are omitted while live peers remain", () => { - const archived = agent({ pubkey: "a".repeat(64), personaId: null }); - const live = agent({ pubkey: "b".repeat(64), personaId: null }); + const archived = agent("a".repeat(64), { personaId: null }); + const live = agent("b".repeat(64), { personaId: null }); const isArchived = (pubkey) => pubkey === archived.pubkey; const { ungrouped } = buildUnifiedGroups([], [archived, live], isArchived); assert.deepEqual( - ungrouped.map((agent) => agent.pubkey), + ungrouped.map((candidate) => candidate.pubkey), [live.pubkey], ); }); test("archived unknown-persona agents are omitted while live peers remain", () => { - const archived = agent({ pubkey: "a".repeat(64), personaId: "orphan" }); - const live = agent({ pubkey: "b".repeat(64), personaId: "orphan" }); + const archived = agent("a".repeat(64), { personaId: "orphan" }); + const live = agent("b".repeat(64), { personaId: "orphan" }); const isArchived = (pubkey) => pubkey === archived.pubkey; - // No persona matches "orphan", so both land in the unknown bucket. const { unknown } = buildUnifiedGroups([], [archived, live], isArchived); assert.deepEqual( - unknown.map((agent) => agent.pubkey), + unknown.map((candidate) => candidate.pubkey), [live.pubkey], ); }); -test("matched persona groups keep their full instance list including archived", () => { - const archived = agent({ pubkey: "a".repeat(64), personaId: "persona-1" }); - const live = agent({ pubkey: "b".repeat(64), personaId: "persona-1" }); +test("matched persona groups retain archived instances for profile history", () => { + const archived = agent("a".repeat(64)); + const live = agent("b".repeat(64)); const isArchived = (pubkey) => pubkey === archived.pubkey; - // The card resolves its own target via pickProfileAgent; the group keeps the - // archived record so an all-archived persona still forms a card in - // persona-only mode rather than vanishing from the library. - const { groups } = buildUnifiedGroups( - [persona()], - [archived, live], - isArchived, - ); + const { groups } = buildUnifiedGroups([fizz], [archived, live], isArchived); - assert.equal(groups.length, 1); - assert.deepEqual( - groups[0].agents.map((agent) => agent.pubkey).sort(), - [archived.pubkey, live.pubkey].sort(), - ); + assert.deepEqual(groups, [{ persona: fizz, agents: [archived, live] }]); + assert.deepEqual(profileAgentsForGroup(groups[0].agents, isArchived), [live]); }); test("a fail-open predicate keeps every standalone agent discoverable", () => { - const first = agent({ pubkey: "a".repeat(64), personaId: null }); - const second = agent({ pubkey: "b".repeat(64), personaId: null }); + const first = agent("a".repeat(64), { personaId: null }); + const second = agent("b".repeat(64), { personaId: null }); const { ungrouped } = buildUnifiedGroups([], [first, second], NONE_ARCHIVED); diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.ts b/desktop/src/features/agents/ui/unifiedAgentGroups.ts index 2ddf34d8402..6b57bef996d 100644 --- a/desktop/src/features/agents/ui/unifiedAgentGroups.ts +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.ts @@ -1,3 +1,4 @@ +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] }; @@ -8,8 +9,8 @@ type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] }; * Archived instances are dropped from the standalone `ungrouped` (custom * agents) and `unknown` buckets so a relay-archived identity never shows as a * clickable library card of its own. Matched persona groups keep their full - * instance list — the persona card resolves its own target through - * `pickProfileAgent`, which applies the same `isArchived` filter and falls back + * instance list so profile resolution and history still have the complete + * input. Card rendering applies the same `isArchived` predicate and falls back * to persona-only mode when every instance is archived. `isArchived` is * fail-open (returns `false` while the relay archive snapshot loads). */ @@ -46,3 +47,20 @@ export function buildUnifiedGroups( return { groups, ungrouped, unknown }; } + +export function profileAgentsForGroup( + agents: readonly ManagedAgent[], + isArchived: (pubkey: string) => boolean, +) { + return agents + .filter((agent) => !isArchived(agent.pubkey)) + .sort((left, right) => { + const activeDiff = + Number(isManagedAgentActive(right)) - + Number(isManagedAgentActive(left)); + if (activeDiff !== 0) return activeDiff; + const nameDiff = left.name.localeCompare(right.name); + if (nameDiff !== 0) return nameDiff; + return left.pubkey.localeCompare(right.pubkey); + }); +} diff --git a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx index 7b8a586224e..6706b0c609a 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx @@ -11,6 +11,7 @@ import { import type { IdentityArchiveActions } from "@/features/identity-archive/hooks"; import { ArchiveConfirmDialog } from "@/features/profile/ui/ArchiveConfirmDialog"; +import { useAgentMemoryQuery } from "@/features/agent-memory/hooks"; import type { ManagedAgent } from "@/shared/api/types"; import { AlertDialog, @@ -31,6 +32,7 @@ export function UserProfileAgentManagementRows({ canDeleteAgent, isDeletePending, managedAgent, + channelCount, onCreateCard, onDeleteAgent, onDuplicateAgent, @@ -41,6 +43,7 @@ export function UserProfileAgentManagementRows({ canDeleteAgent: boolean; isDeletePending: boolean; managedAgent?: ManagedAgent; + channelCount: number; /** Mint an agent trading card. Present only for owner-managed personas. */ onCreateCard?: () => void; onDeleteAgent: () => void; @@ -91,9 +94,11 @@ export function UserProfileAgentManagementRows({ ) : null} {canDeleteAgent ? ( ) : null} @@ -194,15 +199,25 @@ function ProfileArchiveAgentRow({ } function ProfileDeleteAgentRow({ + channelCount, isPending, managedAgent, onDelete, + onExport, }: { + channelCount: number; isPending: boolean; managedAgent?: ManagedAgent; onDelete: () => void; + onExport?: () => void; }) { const [confirmOpen, setConfirmOpen] = React.useState(false); + const memoryQuery = useAgentMemoryQuery(managedAgent?.pubkey, { + enabled: confirmOpen && managedAgent !== undefined, + }); + const memoryCount = + memoryQuery.data && + (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length; return ( <> @@ -223,11 +238,15 @@ function ProfileDeleteAgentRow({ {managedAgent ? ( { setConfirmOpen(false); onDelete(); }} + onExport={onExport} onOpenChange={setConfirmOpen} open={confirmOpen} /> @@ -238,44 +257,82 @@ function ProfileDeleteAgentRow({ function AgentDeleteConfirmDialog({ agent, + channelCount, isPending, + memoryCount, + memoriesLoading, onConfirm, + onExport, onOpenChange, open, }: { agent: ManagedAgent; + channelCount: number; isPending: boolean; + memoryCount?: number; + memoriesLoading: boolean; onConfirm: () => void; + onExport?: () => void; onOpenChange: (open: boolean) => void; open: boolean; }) { const isProviderAgent = agent.backend.type === "provider"; + const showExportRecommendation = + onExport !== undefined && memoryCount !== undefined && memoryCount > 0; return ( - Delete this agent? + Delete {agent.name}? - Deleting this agent stops and removes the agent from this community. + This permanently removes the agent, its saved key, and its channel + access. -
    -
  • Removes the local management record and saved agent key
  • -
  • Removes the agent from every channel it belongs to
  • -
  • - Archives the agent's identity on the relay so it no longer - appears in member lists or mention suggestions -
  • -
  • - {isProviderAgent - ? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management." - : "Stops any local agent process before deleting the record"} -
  • -
-

- Archive this agent if you want to hide it instead of removing it. -

+
+
+

Memories

+

+ {memoriesLoading ? "…" : (memoryCount ?? "—")} +

+
+
+

Channels

+

+ {channelCount} +

+
+
+ {showExportRecommendation ? ( +

+ Want a backup?{" "} + + . +

+ ) : null} + {isProviderAgent ? ( +

+ The remote process may keep running if Buzz can't reach it. +

+ ) : null}