diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index b0668616a..ee11c9273 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 47cb78c60..cf1f27fe2 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -8,18 +8,21 @@ 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 { 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; @@ -128,41 +131,96 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { disabled={isPersonasPending} onClick={onOpenCatalog} /> - {groups.map((group) => { - const profileAgent = pickProfileAgent(group.agents); - 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); + 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; })} @@ -309,6 +367,7 @@ function AgentPersonaCard({ } avatarUrl={avatarUrl} dataTestId={`persona-agent-row-${persona.id}`} + identityLabel={agent ? truncatePubkey(agent.pubkey) : null} label={title} modelLabel={modelLabel} onClick={() => { diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs new file mode 100644 index 000000000..1095d85bd --- /dev/null +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildUnifiedGroups, + profileAgentsForGroup, +} from "./unifiedAgentGroups.ts"; + +function agent(pubkey, overrides = {}) { + return { + pubkey, + name: overrides.name ?? "Fizz", + personaId: overrides.personaId ?? "builtin:fizz", + status: overrides.status ?? "stopped", + ...overrides, + }; +} + +const fizz = { id: "builtin:fizz", displayName: "Fizz" }; + +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], + ); + + 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), [ + 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]); + + assert.deepEqual(profileAgentsForGroup(groups[0].agents), [relayRestored]); +}); + +test("a persona with no managed instance remains an empty group", () => { + const { groups } = buildUnifiedGroups([fizz], []); + + assert.deepEqual(groups, [{ persona: fizz, agents: [] }]); + assert.deepEqual(profileAgentsForGroup(groups[0].agents), []); +}); diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.ts b/desktop/src/features/agents/ui/unifiedAgentGroups.ts index 60c44f929..c48b42e22 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[] }; @@ -32,3 +33,14 @@ export function buildUnifiedGroups( return { groups, ungrouped, unknown }; } + +export function profileAgentsForGroup(agents: ManagedAgent[]) { + return [...agents].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/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 7a3925eb6..c919f325c 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -226,6 +226,35 @@ async function countCommandInvocations( ); } +test("Agents view keeps every managed instance that shares a persona visible", async ({ + page, +}) => { + const personaId = "custom:multi-fizz"; + const firstPubkey = "a".repeat(64); + const secondPubkey = "b".repeat(64); + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Fizz", + systemPrompt: "Move fast.", + }, + ], + managedAgents: [ + { pubkey: firstPubkey, name: "Fizz", personaId }, + { pubkey: secondPubkey, name: "Fizz", personaId }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const cards = page.getByTestId(`persona-agent-row-${personaId}`); + await expect(cards).toHaveCount(2); + await expect(cards.nth(0)).toContainText("aaaaaaaa…aaaa"); + await expect(cards.nth(1)).toContainText("bbbbbbbb…bbbb"); + await expect(page.getByLabel("Open actions for Fizz")).toHaveCount(1); +}); + test("catalog hides built-ins and shows the shared-agent empty state", async ({ page, }) => {