Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions desktop/src/features/agents/ui/AgentIdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"). */
Expand All @@ -24,6 +25,7 @@ export function AgentIdentityCard({
avatarUrl,
dataTestId,
label,
identityLabel,
modelLabel,
onClick,
statusBadge,
Expand Down Expand Up @@ -72,6 +74,11 @@ export function AgentIdentityCard({
<span className="min-w-0 truncate font-semibold text-foreground tracking-normal">
{label}
</span>
{identityLabel ? (
<span className="min-w-0 truncate font-mono text-2xs font-normal text-secondary-foreground/75">
{identityLabel}
</span>
) : null}
{modelLabel ? (
<span className="min-w-0 truncate text-xs font-normal text-secondary-foreground/75">
{modelLabel}
Expand Down
139 changes: 101 additions & 38 deletions desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -128,41 +131,96 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
disabled={isPersonasPending}
onClick={onOpenCatalog}
/>
{groups.map((group) => {
const profileAgent = pickProfileAgent(group.agents);
return (
<AgentPersonaCard
actions={(effectiveAvatarUrl, isEffectiveAvatarLoading) => (
<PersonaActionsMenu
isActionPending={
isActionPending || isEffectiveAvatarLoading
}
isPending={isPersonasPending}
persona={group.persona}
linkedAgent={profileAgent}
onDeactivate={onDeactivatePersona}
onDelete={onDeletePersona}
onDuplicate={onDuplicatePersona}
onEdit={onEditPersona}
onShare={(persona, linkedAgent) =>
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<React.ReactElement> = [];

if (profileAgents.length === 0) {
cards.push(
<AgentPersonaCard
actions={(effectiveAvatarUrl, isEffectiveAvatarLoading) => (
<PersonaActionsMenu
isActionPending={
isActionPending || isEffectiveAvatarLoading
}
isPending={isPersonasPending}
persona={group.persona}
linkedAgent={undefined}
onDeactivate={onDeactivatePersona}
onDelete={onDeletePersona}
onDuplicate={onDuplicatePersona}
onEdit={onEditPersona}
onShare={(persona, linkedAgent) =>
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(
<AgentPersonaCard
actions={
index === 0
? (effectiveAvatarUrl, isEffectiveAvatarLoading) => (
<PersonaActionsMenu
isActionPending={
isActionPending || isEffectiveAvatarLoading
}
isPending={isPersonasPending}
persona={group.persona}
linkedAgent={profileAgent}
onDeactivate={onDeactivatePersona}
onDelete={onDeletePersona}
onDuplicate={onDuplicatePersona}
onEdit={onEditPersona}
onShare={(persona, linkedAgent) =>
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;
})}
</div>

Expand Down Expand Up @@ -308,7 +366,12 @@ 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={() => {
Expand Down
71 changes: 71 additions & 0 deletions desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs
Original file line number Diff line number Diff line change
@@ -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), []);
});
12 changes: 12 additions & 0 deletions desktop/src/features/agents/ui/unifiedAgentGroups.ts
Original file line number Diff line number Diff line change
@@ -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[] };
Expand Down Expand Up @@ -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);
});
}
Loading
Loading