diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx new file mode 100644 index 000000000..a66fb9cd7 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -0,0 +1,69 @@ +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; + modelLabel: string; + onClick: () => void; +}; + +export function AgentIdentityCard({ + actions, + ariaLabel, + avatarUrl, + dataTestId, + label, + modelLabel, + onClick, +}: AgentIdentityCardProps) { + const trimmedAvatarUrl = avatarUrl?.trim() || null; + + return ( +
+ + + {actions ? ( +
{actions}
+ ) : null} + +
+ + {label} + + + {modelLabel} + +
+
+ ); +} 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..71fa31065 --- /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 fontSize = Math.round(Math.min(40, Math.max(22, size * 0.28))); + + 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..17d129e6f --- /dev/null +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -0,0 +1,180 @@ +import type { ReactNode } from "react"; +import { 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 { 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; + teamId: string; + teamName: string; + version?: string | null; +}; + +const MAX_VISIBLE_MEMBER_AVATARS = 4; + +export function TeamIdentityCard({ + actions, + children, + dataTestId, + isSymlink = false, + memberCount, + personas, + sourceDir, + symlinkTarget, + teamName, + version, +}: TeamIdentityCardProps) { + const footerModelLabel = getTeamFooterModelLabel(personas); + + return ( + +
+
+ {isSymlink ? ( + + + + + + + +

Linked from {symlinkTarget ?? sourceDir}

+
+
+ ) : null} + {version ? ( + + v{version} + + ) : 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) => formatFooterModelLabel(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"; +} + +function formatFooterModelLabel(model: string | null | undefined) { + const trimmed = model?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "Auto"; +} diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 4d0e4b66d..daf4f8151 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} + teamId={team.id} + 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 +225,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 eede54956..a63018443 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -2,22 +2,28 @@ import * as React from "react"; import { ChevronDown, ChevronRight, + Clipboard, Ellipsis, + FileText, OctagonX, - Plus, + Pencil, + Play, + Power, + Square, Trash2, + UserPlus, } from "lucide-react"; +import { toast } from "sonner"; -import { isPersonaActive } from "@/features/agents/lib/catalog"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; -import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; +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 { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -26,11 +32,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Skeleton } from "@/shared/ui/skeleton"; -import { AgentGroupRows } from "./AgentGroupRows"; -import { PersonaActionsMenu } from "./PersonaActionsMenu"; -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 { EditAgentDialog } from "./EditAgentDialog"; type UnifiedAgentsSectionProps = { actionErrorMessage: string | null; @@ -52,6 +57,7 @@ type UnifiedAgentsSectionProps = { onBulkStopRunning: () => void; onCreateAgent: () => void; onDeleteAgent: (pubkey: string) => void; + onOpenAgentProfile?: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStartAgent: (pubkey: string) => void; onStopAgent: (pubkey: string) => void; @@ -76,6 +82,9 @@ type UnifiedAgentsSectionProps = { type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] }; +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`; + function buildUnifiedGroups(personas: AgentPersona[], agents: ManagedAgent[]) { const byPersonaId = new Map(); const ungrouped: ManagedAgent[] = []; @@ -109,27 +118,19 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { actionErrorMessage, actionNoticeMessage, agents, - channelIdToName, - channelsByPubkey, agentsError, isActionPending, isAgentsLoading, - logContent, - logError, - logLoading, - personaLabelsById, - presenceLoaded, - presenceLookup, onAddToChannel, onBulkRemoveStopped, onBulkStopRunning, onCreateAgent, onDeleteAgent, + onOpenAgentProfile, onSelectLogAgent, onStartAgent, onStopAgent, onToggleStartOnAppLaunch, - selectedLogAgentPubkey, canChooseCatalog, personas, personasError, @@ -147,14 +148,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, @@ -176,25 +191,24 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { useFeedbackToasts(actionNoticeMessage, actionErrorMessage); useFeedbackToasts(personaFeedbackNoticeMessage, personaFeedbackErrorMessage); const isLoading = isAgentsLoading || isPersonasLoading; - - const rowProps = { - channelIdToName, - channelsByPubkey, + const agentMenuProps = { isActionPending, - logContent, - logError, - logLoading, - personaLabelsById, - presenceLoaded, - presenceLookup, - selectedLogAgentPubkey, onAddToChannel, onDelete: onDeleteAgent, - onSelectLogAgent, + onOpenLogs: onSelectLogAgent, onStart: onStartAgent, onStop: onStopAgent, onToggleStartOnAppLaunch, } as const; + const personaMenuProps = { + isActionPending, + isPersonasPending, + onDeactivatePersona, + onDeletePersona, + onDuplicatePersona, + onEditPersona, + onExportPersona, + } 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 ? ( ) : null}
) : null} {!isLoading && stoppedCount > 0 ? ( -
+

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

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

+

{agentsError.message}

) : null} {personasError ? ( -

+

{personasError.message}

) : null} @@ -351,43 +337,417 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); } +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]; +} + +type AgentMenuProps = { + isActionPending: boolean; + onAddToChannel: (agent: ManagedAgent) => void; + onDelete: (pubkey: string) => void; + onOpenLogs: (pubkey: string) => void; + onStart: (pubkey: string) => void; + onStop: (pubkey: string) => void; + onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; +}; + +type PersonaMenuProps = { + isActionPending: boolean; + isPersonasPending: boolean; + onDeactivatePersona: (persona: AgentPersona) => void; + onDeletePersona: (persona: AgentPersona) => void; + onDuplicatePersona: (persona: AgentPersona) => void; + onEditPersona: (persona: AgentPersona) => void; + onExportPersona: (persona: AgentPersona) => void; +}; + +function AgentPersonaCard({ + agent, + agentMenuProps, + persona, + personaMenuProps, + onOpenAgentProfile, +}: { + agent: ManagedAgent | undefined; + agentMenuProps: AgentMenuProps; + persona: AgentPersona; + personaMenuProps: PersonaMenuProps; + onOpenAgentProfile?: (pubkey: string) => 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; + + return ( + + } + ariaLabel={`${title} agent profile`} + avatarUrl={avatarUrl} + dataTestId={`persona-agent-row-${persona.id}`} + label={title} + modelLabel={modelLabel} + onClick={() => { + if (agent && onOpenAgentProfile) { + onOpenAgentProfile(agent.pubkey); + return; + } + personaMenuProps.onEditPersona(persona); + }} + /> + ); +} + +function StandaloneAgentCard({ + agent, + agentMenuProps, + onOpenAgentProfile, +}: { + agent: ManagedAgent; + agentMenuProps: AgentMenuProps; + onOpenAgentProfile?: (pubkey: string) => void; +}) { + const title = agent.name; + const profileQuery = useUserProfileQuery(agent.pubkey); + + return ( + } + ariaLabel={`${title} agent profile`} + avatarUrl={profileQuery.data?.avatarUrl} + dataTestId={`managed-agent-${agent.pubkey}`} + label={title} + modelLabel={formatAgentModelLabel(agent.model)} + onClick={() => { + if (onOpenAgentProfile) { + onOpenAgentProfile(agent.pubkey); + } else { + agentMenuProps.onOpenLogs(agent.pubkey); + } + }} + /> + ); +} + +function AgentPersonaActionsMenu({ + agent, + agentMenuProps, + persona, + personaMenuProps, +}: { + agent: ManagedAgent | undefined; + agentMenuProps: AgentMenuProps; + persona: AgentPersona; + personaMenuProps: PersonaMenuProps; +}) { + const [editOpen, setEditOpen] = React.useState(false); + const disabled = + personaMenuProps.isActionPending || personaMenuProps.isPersonasPending; + + return ( + <> + + + + + event.preventDefault()} + > + {agent ? ( + <> + setEditOpen(true)} + /> + + + ) : null} + {!persona.isBuiltIn ? ( + personaMenuProps.onEditPersona(persona)} + > + + Edit persona + + ) : null} + personaMenuProps.onDuplicatePersona(persona)} + > + + Duplicate persona + + personaMenuProps.onExportPersona(persona)} + > + + Export persona + + + {persona.isBuiltIn ? ( + personaMenuProps.onDeactivatePersona(persona)} + > + + Remove from My Agents + + ) : persona.sourceTeam ? ( + + + Managed by team + + ) : ( + personaMenuProps.onDeletePersona(persona)} + > + + Delete persona + + )} + + + + {agent ? ( + + ) : null} + + ); +} + +function AgentActionsMenu({ + agent, + isActionPending, + onAddToChannel, + onDelete, + onOpenLogs, + onStart, + onStop, + onToggleStartOnAppLaunch, +}: { agent: ManagedAgent } & AgentMenuProps) { + const [editOpen, setEditOpen] = React.useState(false); + + return ( + <> + + + + + event.preventDefault()} + > + setEditOpen(true)} + /> + + + + + + ); +} + +function AgentActionItems({ + agent, + isActionPending, + onAddToChannel, + onDelete, + onEdit, + onOpenLogs, + onStart, + onStop, + onToggleStartOnAppLaunch, +}: { agent: ManagedAgent; onEdit?: () => void } & AgentMenuProps) { + const isActive = isManagedAgentActive(agent); + + return ( + <> + {agent.backend.type === "provider" ? ( + <> + onStart(agent.pubkey)} + > + + {isActive ? "Redeploy" : "Deploy"} + + onStop(agent.pubkey)} + > + + Shutdown + + + ) : isActive ? ( + onStop(agent.pubkey)} + > + + Stop + + ) : ( + onStart(agent.pubkey)} + > + + Spawn + + )} + + {agent.backend.type !== "provider" && onEdit ? ( + + + Edit agent + + ) : null} + + onAddToChannel(agent)} + > + + Add to channel + + + { + await navigator.clipboard.writeText(agent.pubkey); + toast.success("Copied pubkey to clipboard"); + }} + > + + Copy pubkey + + + {agent.backend.type === "local" ? ( + onOpenLogs(agent.pubkey)}> + + View logs + + ) : null} + + {agent.backend.type === "local" ? ( + + onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch) + } + > + + {agent.startOnAppLaunch ? "Disable auto-start" : "Enable auto-start"} + + ) : null} + + + + onDelete(agent.pubkey)} + > + + Delete + + + ); +} + +function formatAgentModelLabel(model: string | null | undefined) { + const trimmed = model?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "Auto"; +} + +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 ( +
+ + +
); } @@ -580,38 +872,47 @@ function CollapsibleAgentGroup({ groupKey, label, agents, + agentMenuProps, collapsed, onToggle, - rowProps, + onOpenAgentProfile, }: { groupKey: string; label: string; agents: ManagedAgent[]; + agentMenuProps: AgentMenuProps; collapsed: ReadonlySet; 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/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 ( +
+ +
+ ); +}