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.
-