diff --git a/desktop/src/features/agents/lib/formatAgentModelLabel.ts b/desktop/src/features/agents/lib/formatAgentModelLabel.ts new file mode 100644 index 000000000..6c32d5393 --- /dev/null +++ b/desktop/src/features/agents/lib/formatAgentModelLabel.ts @@ -0,0 +1,8 @@ +/** + * Returns a human-readable model label for an agent or persona, falling back to + * "Auto" when no model is set (empty or whitespace-only). + */ +export function formatAgentModelLabel(model: string | null | undefined) { + const trimmed = model?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "Auto"; +} diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx new file mode 100644 index 000000000..5c27d5451 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { cn } from "@/shared/lib/cn"; +import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; + +type AgentIdentityCardProps = { + actions?: ReactNode; + ariaLabel: string; + avatarUrl?: string | null; + dataTestId: string; + label: string; + errorLabel?: string | null; + modelControl?: ReactNode; + modelLabel: string; + onClick: () => void; + status?: ReactNode; +}; + +export function AgentIdentityCard({ + actions, + ariaLabel, + avatarUrl, + dataTestId, + errorLabel, + label, + modelControl, + modelLabel, + onClick, + status, +}: AgentIdentityCardProps) { + const trimmedAvatarUrl = avatarUrl?.trim() || null; + + return ( +
+ + + {actions ? ( +
{actions}
+ ) : null} + + {status ? ( +
+ {status} +
+ ) : null} + +
+ + {label} + + {modelControl ?? ( + + {modelLabel} + + )} + {errorLabel ? ( + + {errorLabel} + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f80207beb..7305a9987 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -75,13 +75,6 @@ export function AgentsView() { } isActionPending={isActionPending} isAgentsLoading={agents.managedAgentsQuery.isLoading} - logContent={agents.managedAgentLogQuery.data?.content ?? null} - logError={ - agents.managedAgentLogQuery.error instanceof Error - ? agents.managedAgentLogQuery.error - : null - } - logLoading={agents.managedAgentLogQuery.isLoading} personaLabelsById={personas.personaLabelsById} presenceLoaded={agents.managedPresenceQuery.isSuccess} presenceLookup={agents.managedPresenceQuery.data ?? {}} @@ -100,8 +93,6 @@ export function AgentsView() { onOpenPersonaProfile={(persona) => { openPersonaProfilePanel?.(persona); }} - onSelectLogAgent={agents.setLogAgentPubkey} - selectedLogAgentPubkey={agents.logAgentPubkey} // Persona props canChooseCatalog={personas.catalogPersonas.length > 0} personas={personas.libraryPersonas} diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx new file mode 100644 index 000000000..3efd91e19 --- /dev/null +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { Plus } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +type CreateIdentityCardProps = React.ButtonHTMLAttributes & { + ariaLabel: string; + dataTestId: string; + label: string; +}; + +export const CreateIdentityCard = React.forwardRef< + HTMLButtonElement, + CreateIdentityCardProps +>(function CreateIdentityCard( + { ariaLabel, className, dataTestId, label, ...buttonProps }, + ref, +) { + return ( + + ); +}); diff --git a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx new file mode 100644 index 000000000..0381ebc03 --- /dev/null +++ b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx @@ -0,0 +1,59 @@ +import { UserRound } from "lucide-react"; + +import { getInitials } from "@/shared/lib/initials"; +import { cn } from "@/shared/lib/cn"; + +const IDENTITY_INITIAL_AVATAR_CLASS_NAMES = [ + "bg-muted text-foreground", + "bg-secondary text-secondary-foreground", + "bg-accent text-accent-foreground", + "bg-card text-card-foreground", + "bg-popover text-popover-foreground", + "bg-background text-foreground", +] as const; + +type IdentityInitialsAvatarProps = { + className?: string; + colorIndex?: number; + colorSeed?: string; + label: string; + size: number; +}; + +export function IdentityInitialsAvatar({ + className, + colorIndex, + colorSeed, + label, + size, +}: IdentityInitialsAvatarProps) { + const initials = getInitials(label); + const seed = colorSeed ?? (label || "agent"); + const paletteIndex = colorIndex ?? getStableColorIndex(seed); + const colorClassName = + IDENTITY_INITIAL_AVATAR_CLASS_NAMES[ + paletteIndex % IDENTITY_INITIAL_AVATAR_CLASS_NAMES.length + ]; + const textSizeClassName = size >= 80 ? "text-3xl" : "text-xl"; + + return ( + + {initials.length > 0 ? initials : } + + ); +} + +function getStableColorIndex(seed: string) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash; +} diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx new file mode 100644 index 000000000..c4b8f549b --- /dev/null +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -0,0 +1,193 @@ +import type { ReactNode } from "react"; +import { Info, Link, Users } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { AgentPersona } from "@/shared/api/types"; +import { Card } from "@/shared/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; +import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; + +type TeamIdentityCardProps = { + actions: ReactNode; + children?: ReactNode; + dataTestId: string; + description?: string | null; + isSymlink?: boolean; + memberCount: number; + personas: AgentPersona[]; + sourceDir?: string | null; + symlinkTarget?: string | null; + teamName: string; + version?: string | null; +}; + +const MAX_VISIBLE_MEMBER_AVATARS = 4; + +export function TeamIdentityCard({ + actions, + children, + dataTestId, + description, + isSymlink = false, + memberCount, + personas, + sourceDir, + symlinkTarget, + teamName, + version, +}: TeamIdentityCardProps) { + const footerModelLabel = getTeamFooterModelLabel(personas); + const trimmedDescription = description?.trim(); + + return ( + +
+
+ {isSymlink ? ( + + + + + + + +

Linked from {symlinkTarget ?? sourceDir}

+
+
+ ) : null} + {version ? ( + + v{version} + + ) : null} + {trimmedDescription ? ( + + + + + +

{trimmedDescription}

+
+
+ ) : null} +
+ +
{actions}
+ + + +
+ + {teamName} + + + {footerModelLabel} + +
+
+ {children} +
+ ); +} + +function TeamAvatarRow({ + memberCount, + personas, + teamName, +}: { + memberCount: number; + personas: AgentPersona[]; + teamName: string; +}) { + const visiblePersonas = personas.slice(0, MAX_VISIBLE_MEMBER_AVATARS); + const overflowCount = Math.max(0, memberCount - visiblePersonas.length); + + if (visiblePersonas.length === 0 && overflowCount === 0) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+ {visiblePersonas.map((persona, index) => ( + + ))} + {overflowCount > 0 ? ( + + +{overflowCount} + + ) : null} +
+
+ ); +} + +function TeamAvatarItem({ + index, + persona, +}: { + index: number; + persona: AgentPersona; +}) { + const avatarUrl = persona.avatarUrl?.trim() ?? null; + + return ( +
+ {avatarUrl ? ( + + ) : ( + + )} +
+ ); +} + +function getTeamFooterModelLabel(personas: AgentPersona[]) { + const modelLabels = personas + .map((persona) => formatAgentModelLabel(persona.model)) + .filter((model): model is string => Boolean(model)); + + if (modelLabels.length === 0) return "Auto"; + + const uniqueModels = new Map( + modelLabels.map((model) => [model.toLowerCase(), model]), + ); + + return uniqueModels.size === 1 + ? (uniqueModels.values().next().value ?? "Auto") + : "Mixed models"; +} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 4d0e4b66d..095f8d0ef 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -4,17 +4,12 @@ import { Ellipsis, FolderOpen, FolderSync, - Info, - Link, Pencil, Rocket, Trash2, - Upload, - Users, } from "lucide-react"; import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona, AgentTeam } from "@/shared/api/types"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; import { @@ -24,12 +19,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Card } from "@/shared/ui/card"; -import { Skeleton } from "@/shared/ui/skeleton"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { CreateNewButton } from "./CreateNewButton"; +import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; +import { CreateIdentityCard } from "./CreateIdentityCard"; +import { TeamIdentityCard } from "./TeamIdentityCard"; -const MAX_VISIBLE_AVATARS = 4; +const TEAM_CARD_COLUMN_CLASS = "w-full"; +const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`; type TeamsSectionProps = { teams: AgentTeam[]; @@ -43,10 +38,10 @@ type TeamsSectionProps = { onExport: (team: AgentTeam) => void; onDelete: (team: AgentTeam) => void; onAddToChannel: (team: AgentTeam) => void; - onImportFile: (fileBytes: number[], fileName: string) => void; - onInstallFromDirectory: () => void; onSync: (team: AgentTeam) => void; onRevealInFinder: (team: AgentTeam) => void; + onImportFile: (fileBytes: number[], fileName: string) => void; + onInstallFromDirectory?: () => void; }; export function TeamsSection({ @@ -61,10 +56,10 @@ export function TeamsSection({ onExport, onDelete, onAddToChannel, - onImportFile, - onInstallFromDirectory, onSync, onRevealInFinder, + onImportFile, + onInstallFromDirectory, }: TeamsSectionProps) { const { fileInputRef, @@ -83,144 +78,64 @@ export function TeamsSection({ {isDragOver ? (

- Drop .team.json to import + Drop .team.json or .zip to import

) : null} + -
+

My teams

-

+

Saved groups from My Agents that you can add to a channel together.

- -
- - -
{isLoading ? ( -
- {["first", "second", "third"].map((key) => ( - -
- -
- - -
-
-
- ))} +
+ + +
) : null} - {!isLoading && teams.length > 0 ? ( -
+ {!isLoading ? ( +
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); - const visible = resolution.resolvedPersonas.slice( - 0, - MAX_VISIBLE_AVATARS, - ); - const overflow = - resolution.resolvedPersonas.length - visible.length; const missingPersonaCount = resolution.missingPersonaCount; const hasMissingPersonas = resolution.hasMissingPersonas; return ( - -
-
-
- -

- {team.name} -

- {team.isSymlink ? ( - - - - - - - -

- Linked from {team.symlinkTarget ?? team.sourceDir} -

-
-
- ) : null} - {team.version ? ( - - v{team.version} - - ) : null} - {team.description ? ( - - - - - -

{team.description}

-
-
- ) : null} -
- -
-
- {visible.map((persona) => ( - - ))} - {overflow > 0 ? ( - - +{overflow} - - ) : null} -
- - {team.personaIds.length}{" "} - {team.personaIds.length === 1 ? "persona" : "personas"} - -
-
- +
- + } + dataTestId={`team-card-${team.id}`} + description={team.description} + isSymlink={team.isSymlink} + key={team.id} + memberCount={team.personaIds.length} + personas={resolution.resolvedPersonas} + sourceDir={team.sourceDir} + symlinkTarget={team.symlinkTarget} + teamName={team.name} + version={team.version} + > {hasMissingPersonas ? ( -

+

{missingPersonaCount} persona {missingPersonaCount === 1 ? "" : "s"} in this team{" "} {missingPersonaCount === 1 ? "is" : "are"} no longer in your @@ -299,42 +224,68 @@ export function TeamsSection({ exporting.

) : null} -
+ ); })} - +
) : null} - {!isLoading && teams.length === 0 ? ( - - ) : null} - {error ? ( -

+

{error.message}

) : null} ); } + +function NewTeamCard({ + isPending, + onCreate, + onImport, + onInstallFromDirectory, +}: { + isPending: boolean; + onCreate: () => void; + onImport: () => void; + onInstallFromDirectory?: () => void; +}) { + return ( + + + + + event.preventDefault()} + > + + Create team + + {onInstallFromDirectory ? ( + + Install from directory + + ) : null} + + Import team file + + + + ); +} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index eceb9d3e2..5fe114a30 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -4,20 +4,24 @@ import { ChevronRight, Ellipsis, OctagonX, - Plus, Trash2, } from "lucide-react"; -import { isPersonaActive } from "@/features/agents/lib/catalog"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; +import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; -import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; +import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { ModelPicker } from "@/features/agents/ui/ModelPicker"; +import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent, PresenceLookup, } from "@/shared/api/types"; -import { Badge } from "@/shared/ui/badge"; +import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; +import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -26,10 +30,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Skeleton } from "@/shared/ui/skeleton"; -import { AgentGroupRows } from "./AgentGroupRows"; -import { PersonaIdentity } from "./PersonaIdentity"; -import { PersonaLibraryEntryPoints } from "./PersonaLibraryEntryPoints"; +import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; +import { AgentIdentityCard } from "./AgentIdentityCard"; +import { CreateIdentityCard } from "./CreateIdentityCard"; +import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups"; type UnifiedAgentsSectionProps = { actionErrorMessage: string | null; @@ -40,9 +44,6 @@ type UnifiedAgentsSectionProps = { agentsError: Error | null; isActionPending: boolean; isAgentsLoading: boolean; - logContent: string | null; - logError: Error | null; - logLoading: boolean; personaLabelsById: Record; presenceLoaded: boolean; presenceLookup: PresenceLookup; @@ -51,8 +52,6 @@ type UnifiedAgentsSectionProps = { onCreateAgent: () => void; onOpenAgentProfile: (pubkey: string) => void; onOpenPersonaProfile: (persona: AgentPersona) => void; - onSelectLogAgent: (pubkey: string | null) => void; - selectedLogAgentPubkey: string | null; canChooseCatalog: boolean; personas: AgentPersona[]; personasError: Error | null; @@ -65,50 +64,17 @@ type UnifiedAgentsSectionProps = { onImportPersonaFile: (fileBytes: number[], fileName: string) => void; }; -type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] }; - -function buildUnifiedGroups(personas: AgentPersona[], agents: ManagedAgent[]) { - const byPersonaId = new Map(); - const ungrouped: ManagedAgent[] = []; - - for (const agent of agents) { - if (!agent.personaId) { - ungrouped.push(agent); - } else { - const list = byPersonaId.get(agent.personaId) ?? []; - list.push(agent); - byPersonaId.set(agent.personaId, list); - } - } - - const matched = new Set(); - const groups: PersonaGroup[] = personas.map((p) => { - matched.add(p.id); - return { persona: p, agents: byPersonaId.get(p.id) ?? [] }; - }); - - const unknown: ManagedAgent[] = []; - for (const [id, list] of byPersonaId) { - if (!matched.has(id)) unknown.push(...list); - } - - return { groups, ungrouped, unknown }; -} +const AGENT_CARD_COLUMN_CLASS = "w-full"; +const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { actionErrorMessage, actionNoticeMessage, agents, - channelIdToName, - channelsByPubkey, agentsError, isActionPending, isAgentsLoading, - logContent, - logError, - logLoading, - personaLabelsById, presenceLoaded, presenceLookup, onBulkRemoveStopped, @@ -116,8 +82,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onCreateAgent, onOpenAgentProfile, onOpenPersonaProfile, - onSelectLogAgent, - selectedLogAgentPubkey, canChooseCatalog, personas, personasError, @@ -130,14 +94,28 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onImportPersonaFile, } = props; - const runningCount = agents.filter((a) => isManagedAgentActive(a)).length; + const runningCount = agents.filter((agent) => + isManagedAgentActive(agent), + ).length; const stoppedCount = agents.filter( - (a) => a.status === "stopped" || a.status === "not_deployed", + (agent) => agent.status === "stopped" || agent.status === "not_deployed", ).length; const { groups, ungrouped, unknown } = React.useMemo( () => buildUnifiedGroups(personas, agents), [personas, agents], ); + const additionalPersonaAgents = React.useMemo(() => { + const additional: ManagedAgent[] = []; + for (const group of groups) { + const primary = pickProfileAgent(group.agents); + for (const agent of group.agents) { + if (primary?.pubkey !== agent.pubkey) { + additional.push(agent); + } + } + } + return additional; + }, [groups]); const [collapsed, setCollapsed] = React.useState>(new Set()); const { fileInputRef, @@ -160,21 +138,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { useFeedbackToasts(personaFeedbackNoticeMessage, personaFeedbackErrorMessage); const isLoading = isAgentsLoading || isPersonasLoading; - const rowProps = { - channelIdToName, - channelsByPubkey, - isActionPending, - logContent, - logError, - logLoading, - personaLabelsById, - presenceLoaded, - presenceLookup, - selectedLogAgentPubkey, - onOpenProfile: onOpenAgentProfile, - onSelectLogAgent, - } as const; - return (
{isLoading ? : null} - {!isLoading && personas.length === 0 && agents.length === 0 ? ( - - ) : null} - - {!isLoading && (personas.length > 0 || agents.length > 0) ? ( + {!isLoading ? (
- {groups.map((g) => { - const isCollapsed = collapsed.has(g.persona.id); - const hasAgents = g.agents.length > 0; - const isDeactivated = !isPersonaActive(g.persona); - return ( -
-
- -
- {isDeactivated ? ( - Deactivated - ) : !hasAgents ? ( - Inactive - ) : null} - -
-
- {!isCollapsed && hasAgents ? ( - - ) : null} -
- ); - })} +
+ {groups.map((group) => { + const profileAgent = pickProfileAgent(group.agents); + return ( + + ); + })} + +
+ {additionalPersonaAgents.length > 0 ? ( + + ) : null} {unknown.length > 0 ? ( ) : null} {ungrouped.length > 0 ? ( @@ -290,15 +222,19 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} groupKey="__ungrouped__" label="Custom Agents" - rowProps={rowProps} + presenceLoaded={presenceLoaded} + presenceLookup={presenceLookup} onToggle={toggle} + onOpenAgentProfile={onOpenAgentProfile} /> ) : null}
) : null} {!isLoading && stoppedCount > 0 ? ( -
+

{stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"}

@@ -316,12 +252,16 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ) : null} {agentsError ? ( -

+

{agentsError.message}

) : null} {personasError ? ( -

+

{personasError.message}

) : null} @@ -329,43 +269,157 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); } +function AgentPersonaCard({ + agent, + persona, + presenceLoaded, + presenceLookup, + onOpenAgentProfile, + onOpenPersonaProfile, +}: { + agent: ManagedAgent | undefined; + persona: AgentPersona; + presenceLoaded: boolean; + presenceLookup: PresenceLookup; + onOpenAgentProfile: (pubkey: string) => void; + onOpenPersonaProfile: (persona: AgentPersona) => void; +}) { + const title = persona.displayName; + const modelLabel = formatAgentModelLabel(agent?.model ?? persona.model); + const profileQuery = useUserProfileQuery(agent?.pubkey); + const avatarUrl = agent + ? firstAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl) + : persona.avatarUrl; + const friendlyError = agent + ? friendlyAgentLastError(agent.lastError)?.copy + : null; + + return ( + : undefined} + modelLabel={modelLabel} + onClick={() => { + if (agent) { + onOpenAgentProfile(agent.pubkey); + return; + } + onOpenPersonaProfile(persona); + }} + status={ + agent ? ( + + ) : null + } + /> + ); +} + +function StandaloneAgentCard({ + agent, + presenceLoaded, + presenceLookup, + onOpenAgentProfile, +}: { + agent: ManagedAgent; + presenceLoaded: boolean; + presenceLookup: PresenceLookup; + onOpenAgentProfile: (pubkey: string) => void; +}) { + const title = agent.name; + const profileQuery = useUserProfileQuery(agent.pubkey); + const friendlyError = friendlyAgentLastError(agent.lastError)?.copy; + + return ( + } + modelLabel={formatAgentModelLabel(agent.model)} + onClick={() => { + onOpenAgentProfile(agent.pubkey); + }} + status={ + + } + /> + ); +} + +function AgentCardStatus({ + agent, + presenceLoaded, + presenceLookup, +}: { + agent: ManagedAgent; + presenceLoaded: boolean; + presenceLookup: PresenceLookup; +}) { + const activeTurns = useActiveAgentTurns(agent.pubkey); + const presenceStatus = presenceLookup[normalizePubkey(agent.pubkey)]; + + return ( + 0} + presenceLoaded={presenceLoaded} + presenceStatus={presenceStatus} + status={agent.status} + /> + ); +} + +function firstAvatarUrl( + ...candidates: Array +): string | null { + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} + function SectionHeader({ agentCount, - canChooseCatalog, fileInputRef, handleFileChange, isActionPending, - isPersonasPending, - openFilePicker, runningCount, stoppedCount, onBulkRemoveStopped, onBulkStopRunning, - onChooseCatalog, - onCreateAgent, - onCreatePersona, }: { agentCount: number; - canChooseCatalog: boolean; fileInputRef: React.RefObject; handleFileChange: (e: React.ChangeEvent) => void; isActionPending: boolean; - isPersonasPending: boolean; - openFilePicker: () => void; runningCount: number; stoppedCount: number; onBulkRemoveStopped: () => void; onBulkStopRunning: () => void; - onChooseCatalog: () => void; - onCreateAgent: () => void; - onCreatePersona: () => void; }) { return ( -
+

Your Agents

-

- Personas and their deployed agent instances. +

+ Agents in this workspace.

-
- {agentCount > 0 ? ( - - - - - e.preventDefault()} - > - - - Stop all running ({runningCount}) - - - - Remove all stopped ({stoppedCount}) - - - - ) : null} + {agentCount > 0 ? ( - e.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} > - Persona - - {canChooseCatalog ? ( - - Choose from Catalog... - - ) : null} - - - Custom Agent + + Stop all running ({runningCount}) - - Import persona file + + + Remove all stopped ({stoppedCount}) -
-
- ); -} - -function LoadingSkeleton() { - return ( -
- {["a", "b", "c"].map((k, index) => ( -
-
-
- - -
- - -
- -
- {index === 1 ? ( - - ) : null} - -
-
-
-
-
-
-
- - -
-
- - -
-
- - -
- {index === 0 ? ( -
- - -
- ) : null} -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- ))} + ) : null}
); } -function EmptyState({ +function NewAgentCard({ canChooseCatalog, isPersonasPending, openFilePicker, onChooseCatalog, + onCreateAgent, onCreatePersona, }: { canChooseCatalog: boolean; isPersonasPending: boolean; openFilePicker: () => void; onChooseCatalog: () => void; + onCreateAgent: () => void; onCreatePersona: () => void; }) { return ( -
-

No agents yet

-

- Create a persona or choose one from the catalog, then deploy it to a - channel. -

-
- + + -
+ + event.preventDefault()} + > + + Persona + + {canChooseCatalog ? ( + + Choose from Catalog... + + ) : null} + + + Custom Agent + + + Import persona file + + + + ); +} + +function LoadingSkeleton() { + return ( +
+ + +
); } @@ -559,37 +545,49 @@ function CollapsibleAgentGroup({ label, agents, collapsed, + presenceLoaded, + presenceLookup, onToggle, - rowProps, + onOpenAgentProfile, }: { groupKey: string; label: string; agents: ManagedAgent[]; collapsed: ReadonlySet; + presenceLoaded: boolean; + presenceLookup: PresenceLookup; onToggle: (key: string) => void; - rowProps: Omit, "agents">; + onOpenAgentProfile: (pubkey: string) => void; }) { const isCollapsed = collapsed.has(groupKey); return ( -
-
- -
- {!isCollapsed ? : null} +
+ + {!isCollapsed ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : null}
); } diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.ts b/desktop/src/features/agents/ui/unifiedAgentGroups.ts new file mode 100644 index 000000000..a2d1b987f --- /dev/null +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.ts @@ -0,0 +1,44 @@ +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; + +type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] }; + +export function buildUnifiedGroups( + personas: AgentPersona[], + agents: ManagedAgent[], +) { + const byPersonaId = new Map(); + const ungrouped: ManagedAgent[] = []; + + for (const agent of agents) { + if (!agent.personaId) { + ungrouped.push(agent); + } else { + const list = byPersonaId.get(agent.personaId) ?? []; + list.push(agent); + byPersonaId.set(agent.personaId, list); + } + } + + const matched = new Set(); + const groups: PersonaGroup[] = personas.map((persona) => { + matched.add(persona.id); + return { persona, agents: byPersonaId.get(persona.id) ?? [] }; + }); + + const unknown: ManagedAgent[] = []; + for (const [id, list] of byPersonaId) { + if (!matched.has(id)) unknown.push(...list); + } + + return { groups, ungrouped, unknown }; +} + +export function pickProfileAgent(agents: ManagedAgent[]) { + return [...agents].sort((left, right) => { + const activeDiff = + Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left)); + if (activeDiff !== 0) return activeDiff; + return left.name.localeCompare(right.name); + })[0]; +} diff --git a/desktop/src/shared/ui/identity-card-skeleton.tsx b/desktop/src/shared/ui/identity-card-skeleton.tsx new file mode 100644 index 000000000..86f33421e --- /dev/null +++ b/desktop/src/shared/ui/identity-card-skeleton.tsx @@ -0,0 +1,44 @@ +import { cn } from "@/shared/lib/cn"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type IdentityCardSkeletonProps = { + className?: string; + footerSubtitleWidthClass?: string; + footerTitleWidthClass?: string; + showAction?: boolean; +}; + +export function IdentityCardSkeleton({ + className, + footerSubtitleWidthClass = "w-16", + footerTitleWidthClass = "w-28", + showAction = false, +}: IdentityCardSkeletonProps) { + return ( +
+ {showAction ? ( + + ) : null} + + + +
+ + +
+
+ ); +} + +function SingleAvatarSkeleton() { + return ( +
+ +
+ ); +} diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index ab4d1dfa3..f55c573b7 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -30,10 +30,7 @@ async function gotoApp(page: import("@playwright/test").Page) { } async function openPersonaCatalog(page: import("@playwright/test").Page) { - await page - .getByTestId("agents-library-personas") - .getByRole("button", { name: "New", exact: true }) - .click(); + await page.getByTestId("new-agent-card").click(); await page.getByText("Choose from Catalog...").click(); } diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index 570648598..aa7c89f43 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -69,15 +69,16 @@ async function triggerManagedAgentPrimaryAction( pubkey: string, ) { // Agent lifecycle actions moved from the old per-row dropdown into the - // profile sidebar (PR #1200): the Agents-page row now exposes a "Manage" - // button that opens the profile panel, where a single primary-action button - // toggles Stop (when running/deployed) / Start (when stopped). Open the panel - // for this agent if it isn't already showing it, then click that toggle. + // profile sidebar (PR #1200): the Agents-page surfaces each agent as an + // identity card that opens the profile panel on click, where a single + // primary-action button toggles Stop (when running/deployed) / Start (when + // stopped). Open the panel for this agent if it isn't already showing it, + // then click that toggle. const panel = page.getByTestId("user-profile-panel"); const primaryAction = panel.getByTestId("user-profile-agent-primary-action"); if (!(await primaryAction.isVisible().catch(() => false))) { - const row = page.getByTestId(`managed-agent-${pubkey}`); - await row.getByRole("button", { name: "Manage" }).click(); + const card = page.getByTestId(`managed-agent-${pubkey}`); + await card.getByRole("button", { name: /agent profile$/ }).click(); await expect(panel).toBeVisible(); } await expect(primaryAction).toBeEnabled(); @@ -85,10 +86,7 @@ async function triggerManagedAgentPrimaryAction( } async function openNewAgentMenu(page: import("@playwright/test").Page) { - await page - .getByTestId("agents-library-personas") - .getByRole("button", { name: "New", exact: true }) - .click(); + await page.getByTestId("new-agent-card").click(); } test.beforeEach(async ({ page }) => { diff --git a/desktop/tests/e2e/persona-env-vars.spec.ts b/desktop/tests/e2e/persona-env-vars.spec.ts index e2b25f1bd..1d837599a 100644 --- a/desktop/tests/e2e/persona-env-vars.spec.ts +++ b/desktop/tests/e2e/persona-env-vars.spec.ts @@ -223,10 +223,7 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({ // Open the Agents view, click New > Persona to open the persona dialog. await page.getByTestId("open-agents-view").click(); - await page - .getByTestId("agents-library-personas") - .getByRole("button", { name: "New", exact: true }) - .click(); + await page.getByTestId("new-agent-card").click(); await page.getByRole("menuitem", { name: /^Persona$/ }).click(); // The env vars editor should be present. diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 7e47b76ae..74d66ce65 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -115,10 +115,7 @@ test("create agent supports parallelism and system prompt overrides", async ({ await page.goto("/"); await page.getByTestId("open-agents-view").click(); - await page - .getByTestId("agents-library-personas") - .getByRole("button", { name: "New", exact: true }) - .click(); + await page.getByTestId("new-agent-card").click(); await page.getByText("Custom Agent").click(); await page.getByTestId("agent-name-input").fill(agentName); @@ -137,12 +134,21 @@ test("create agent supports parallelism and system prompt overrides", async ({ await expect(page.getByTestId("agents-library-personas")).toContainText( agentName, ); - const inlineLog = page - .getByTestId("agents-library-personas") - .getByTestId("managed-agent-log-content"); - await expect(inlineLog).toContainText("parallelism=3"); - await expect(inlineLog).toContainText("system prompt override configured"); + // Logs now live in the profile sidebar (PR #1274), not an inline panel. + // Open the new agent's card to reveal the profile panel, then read the + // harness log from the diagnostics view. + await page + .getByRole("button", { name: `${agentName} agent profile` }) + .click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + + await page.getByTestId("user-profile-tab-runtime").click(); + await page.getByTestId("user-profile-diagnostics-ingress").click(); + + const log = page.getByTestId("managed-agent-log-content"); + await expect(log).toContainText("parallelism=3"); + await expect(log).toContainText("system prompt override configured"); }); test("opens a mocked channel from the inbox feed", async ({ page }) => {