diff --git a/desktop/src/features/agents/ui/AgentActionItems.tsx b/desktop/src/features/agents/ui/AgentActionItems.tsx new file mode 100644 index 000000000..5a2718e30 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentActionItems.tsx @@ -0,0 +1,155 @@ +import { + BookmarkPlus, + Clipboard, + FileText, + Pencil, + Play, + Power, + Square, + Trash2, + UserPlus, +} from "lucide-react"; +import { toast } from "sonner"; + +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import type { ManagedAgent } from "@/shared/api/types"; +import { + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/shared/ui/dropdown-menu"; + +export type AgentMenuProps = { + isActionPending: boolean; + onAddToChannel: (agent: ManagedAgent) => void; + onDelete: (pubkey: string) => void; + onOpenLogs: (pubkey: string) => void; + onSaveAsTemplate: (agent: ManagedAgent) => void; + onStart: (pubkey: string) => void; + onStop: (pubkey: string) => void; + onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; +}; + +/** + * The shared dropdown-menu item list for a managed agent. Rendered inside the + * standalone-agent menu, the persona-backed-agent menu, and the agent card + * menu — kept here so all three stay in lockstep. + */ +export function AgentActionItems({ + agent, + isActionPending, + onAddToChannel, + onDelete, + onEdit, + onOpenLogs, + onSaveAsTemplate, + 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} + + {/* Opt-in promote — hidden for persona-backed agents (already reusable). */} + {!agent.personaId ? ( + onSaveAsTemplate(agent)} + > + + Save as persona template + + ) : 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 + + + ); +} 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 44088d490..fbcdd36bc 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -100,6 +100,7 @@ export function AgentsView() { onDeleteAgent={(pubkey) => { void agents.handleDelete(pubkey); }} + onSaveAsTemplate={personas.openSaveAsTemplate} onSelectLogAgent={agents.setLogAgentPubkey} onStartAgent={(pubkey) => { void agents.handleStart(pubkey); 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..9255f9282 --- /dev/null +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -0,0 +1,198 @@ +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 { 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, + 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) => 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..cc3efbec4 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, Trash2, } from "lucide-react"; -import { isPersonaActive } from "@/features/agents/lib/catalog"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +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,11 +32,13 @@ 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 { AgentActionItems, type AgentMenuProps } from "./AgentActionItems"; +import { AgentIdentityCard } from "./AgentIdentityCard"; +import { CreateIdentityCard } from "./CreateIdentityCard"; +import { EditAgentDialog } from "./EditAgentDialog"; +import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; +import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups"; type UnifiedAgentsSectionProps = { actionErrorMessage: string | null; @@ -52,6 +60,8 @@ type UnifiedAgentsSectionProps = { onBulkStopRunning: () => void; onCreateAgent: () => void; onDeleteAgent: (pubkey: string) => void; + onOpenAgentProfile?: (pubkey: string) => void; + onSaveAsTemplate: (agent: ManagedAgent) => void; onSelectLogAgent: (pubkey: string | null) => void; onStartAgent: (pubkey: string) => void; onStopAgent: (pubkey: string) => void; @@ -74,62 +84,34 @@ 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, + selectedLogAgentPubkey, onAddToChannel, onBulkRemoveStopped, onBulkStopRunning, onCreateAgent, onDeleteAgent, + onOpenAgentProfile, + onSaveAsTemplate, onSelectLogAgent, onStartAgent, onStopAgent, onToggleStartOnAppLaunch, - selectedLogAgentPubkey, canChooseCatalog, personas, personasError, @@ -147,14 +129,36 @@ 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 selectedLogAgent = React.useMemo( + () => + selectedLogAgentPubkey + ? (agents.find((agent) => agent.pubkey === selectedLogAgentPubkey) ?? + null) + : null, + [agents, selectedLogAgentPubkey], + ); const [collapsed, setCollapsed] = React.useState>(new Set()); const { fileInputRef, @@ -176,25 +180,25 @@ 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, + onSaveAsTemplate, 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} + {selectedLogAgent ? ( +
+ +
+ ) : null}
) : null} {!isLoading && stoppedCount > 0 ? ( -
+

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

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

+

{agentsError.message}

) : null} {personasError ? ( -

+

{personasError.message}

) : null} @@ -351,43 +348,351 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); } +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, + presenceLoaded, + presenceLookup, + onOpenAgentProfile, +}: { + agent: ManagedAgent | undefined; + agentMenuProps: AgentMenuProps; + persona: AgentPersona; + personaMenuProps: PersonaMenuProps; + presenceLoaded: boolean; + presenceLookup: PresenceLookup; + 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; + const friendlyError = agent + ? friendlyAgentLastError(agent.lastError)?.copy + : null; + + return ( + + } + ariaLabel={`${title} agent profile`} + avatarUrl={avatarUrl} + dataTestId={`persona-agent-row-${persona.id}`} + errorLabel={friendlyError} + label={title} + modelControl={agent ? : undefined} + modelLabel={modelLabel} + onClick={() => { + if (agent && onOpenAgentProfile) { + onOpenAgentProfile(agent.pubkey); + return; + } + if (!persona.isBuiltIn) { + personaMenuProps.onEditPersona(persona); + } + }} + status={ + agent ? ( + + ) : null + } + /> + ); +} + +function StandaloneAgentCard({ + agent, + agentMenuProps, + presenceLoaded, + presenceLookup, + onOpenAgentProfile, +}: { + agent: ManagedAgent; + agentMenuProps: AgentMenuProps; + presenceLoaded: boolean; + presenceLookup: PresenceLookup; + onOpenAgentProfile?: (pubkey: string) => void; +}) { + const title = agent.name; + const profileQuery = useUserProfileQuery(agent.pubkey); + const friendlyError = friendlyAgentLastError(agent.lastError)?.copy; + + return ( + } + ariaLabel={`${title} agent profile`} + avatarUrl={profileQuery.data?.avatarUrl} + dataTestId={`managed-agent-${agent.pubkey}`} + errorLabel={friendlyError} + label={title} + modelControl={} + modelLabel={formatAgentModelLabel(agent.model)} + onClick={() => { + if (onOpenAgentProfile) { + onOpenAgentProfile(agent.pubkey); + } else if (agent.backend.type === "local") { + agentMenuProps.onOpenLogs(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 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, + onSaveAsTemplate, + onStart, + onStop, + onToggleStartOnAppLaunch, +}: { agent: ManagedAgent } & AgentMenuProps) { + const [editOpen, setEditOpen] = React.useState(false); + + return ( + <> + + + + + event.preventDefault()} + > + setEditOpen(true)} + /> + + + + + + ); +} + +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 + + Stop all running ({runningCount}) - {canChooseCatalog ? ( - - Choose from Catalog... - - ) : null} - - - Custom Agent - - - 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 +817,53 @@ function CollapsibleAgentGroup({ groupKey, label, agents, + agentMenuProps, collapsed, + presenceLoaded, + presenceLookup, onToggle, - rowProps, + onOpenAgentProfile, }: { groupKey: string; label: string; agents: ManagedAgent[]; + agentMenuProps: AgentMenuProps; 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/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index 7e39b7288..1dcda856a 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -7,8 +7,54 @@ import { duplicatePersonaDialogState, editPersonaDialogState, importPersonaDialogState, + saveAsPersonaTemplateDialogState, } from "./personaDialogState.ts"; +// Minimal ManagedAgent fixture — only the fields the save-as mapping reads. +function makeManagedAgent(overrides = {}) { + return { + pubkey: "agentpub", + name: "Helper", + personaId: null, + relayUrl: "wss://relay", + acpCommand: "goose", + agentCommand: "/usr/local/bin/goose", + agentCommandOverride: null, + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 0, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: "Be helpful.", + model: "claude-sonnet", + provider: "anthropic", + personaOutOfDate: false, + personaOrphaned: false, + mcpToolsets: null, + envVars: { ANTHROPIC_API_KEY: "sk-test" }, + ...overrides, + }; +} + +function makeRuntime(overrides = {}) { + return { + id: "goose", + label: "Goose", + avatarUrl: "", + availability: "available", + command: "goose", + binaryPath: "/usr/local/bin/goose", + defaultArgs: [], + mcpCommand: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + ...overrides, + }; +} + test("canSubmitPersonaDialog requires a display name but not a system prompt", () => { // Empty system prompt is allowed: core memory is auto-injected, so the // persona prompt is optional. Only the display name gates submission. @@ -258,3 +304,85 @@ test("importPersonaDialogState preserves provider=anthropic", () => { assert.equal(state.initialValues.provider, "anthropic"); }); + +test("saveAsPersonaTemplateDialogState carries agent config into a create draft", () => { + const state = saveAsPersonaTemplateDialogState(makeManagedAgent(), [ + makeRuntime(), + ]); + + assert.equal(state.title, "Save as persona template"); + assert.equal(state.submitLabel, "Save as persona template"); + assert.equal(state.description, "Reuse this setup to create more agents."); + assert.deepEqual(state.initialValues, { + displayName: "Helper", + avatarUrl: "", + systemPrompt: "Be helpful.", + // Reverse-mapped from agentCommand basename → matching runtime id. + runtime: "goose", + model: "claude-sonnet", + provider: "anthropic", + // Persona-only field starts empty; the user fills it in the dialog. + namePool: [], + envVars: { ANTHROPIC_API_KEY: "sk-test" }, + }); +}); + +test("saveAsPersonaTemplateDialogState reverse-maps the runtime by command basename", () => { + // Agent's resolved command is an absolute path; the runtime exposes a bare + // command. commandsMatch normalizes on basename, so they should still pair. + const state = saveAsPersonaTemplateDialogState( + makeManagedAgent({ agentCommand: "/opt/homebrew/bin/goose" }), + [makeRuntime({ id: "goose-runtime", command: "goose" })], + ); + + assert.equal(state.initialValues.runtime, "goose-runtime"); +}); + +test("saveAsPersonaTemplateDialogState falls back to undefined runtime when none match", () => { + // No runtime matches the agent command, or runtimes not loaded yet — the + // dialog then uses its own default-runtime behavior. + const noMatch = saveAsPersonaTemplateDialogState( + makeManagedAgent({ agentCommand: "claude-code-acp" }), + [makeRuntime({ command: "goose" })], + ); + assert.equal(noMatch.initialValues.runtime, undefined); + + const noRuntimes = saveAsPersonaTemplateDialogState(makeManagedAgent(), []); + assert.equal(noRuntimes.initialValues.runtime, undefined); +}); + +test("saveAsPersonaTemplateDialogState skips runtimes with a null command", () => { + // Catalog entries can be unavailable (command: null). Those must not throw + // and must not match — only resolvable commands participate in the map. + const state = saveAsPersonaTemplateDialogState(makeManagedAgent(), [ + makeRuntime({ id: "uninstalled", command: null, availability: "missing" }), + makeRuntime({ id: "goose", command: "goose" }), + ]); + + assert.equal(state.initialValues.runtime, "goose"); +}); + +test("saveAsPersonaTemplateDialogState maps a null provider/model/systemPrompt to undefined/empty", () => { + const state = saveAsPersonaTemplateDialogState( + makeManagedAgent({ provider: null, model: null, systemPrompt: null }), + [], + ); + + assert.equal(state.initialValues.provider, undefined); + assert.equal(state.initialValues.model, undefined); + assert.equal(state.initialValues.systemPrompt, ""); +}); + +test("default managed-agent create stays persona-less (no personaId set)", () => { + // Part 1 regression guard. A default agent create must not carry a + // personaId — that linkage only exists when a persona/template is chosen. + // The save-as flow promotes an existing agent INTO a template; it never + // back-fills personaId onto the source agent. + const agent = makeManagedAgent(); + assert.equal(agent.personaId, null); + + // The promote produces a CreatePersonaInput (no agent personaId mutation), + // and a persona-create draft has no personaId field at all. + const state = saveAsPersonaTemplateDialogState(agent, []); + assert.equal("personaId" in state.initialValues, false); +}); diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index 0a8c2a914..792121c9e 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -1,9 +1,12 @@ import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import type { + AcpRuntimeCatalogEntry, AgentPersona, CreatePersonaInput, + ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; +import { commandsMatch } from "@/features/agents/agentReuse"; export type PersonaDialogState = { description: string; @@ -70,6 +73,58 @@ export function duplicatePersonaDialogState( }; } +/** + * Reverse-map a managed agent's resolved harness command back to an ACP + * runtime ID, so the persona dialog can pre-select the matching runtime. + * Returns `undefined` when no runtime matches (or none are loaded yet) — the + * dialog then falls back to its default-runtime behavior. + */ +function runtimeIdForAgentCommand( + agentCommand: string, + runtimes: readonly AcpRuntimeCatalogEntry[], +): string | undefined { + const match = runtimes.find( + (runtime) => + runtime.command !== null && commandsMatch(runtime.command, agentCommand), + ); + return match?.id; +} + +/** + * Dialog state for the opt-in "Save as persona template" action on an existing + * agent. Prefills the persona editor from the agent so the user reviews and + * confirms before a persona template is created — nothing is minted silently. + * + * Near-lossless promote: name, system prompt, model, provider, and env vars + * copy straight across; the harness command reverse-maps to a runtime ID. + * `namePool` is persona-only and starts empty — the user can fill it in the + * same dialog (it's how a template bulk-adds bots later). + * + * Note: "persona template" is the UI name for what the backend calls a + * `persona` (kind:30175). This builder produces a backend `CreatePersonaInput`. + */ +export function saveAsPersonaTemplateDialogState( + agent: ManagedAgent, + runtimes: readonly AcpRuntimeCatalogEntry[], +): PersonaDialogState { + return { + title: "Save as persona template", + description: "Reuse this setup to create more agents.", + submitLabel: "Save as persona template", + initialValues: { + displayName: agent.name, + avatarUrl: "", + systemPrompt: agent.systemPrompt ?? "", + runtime: runtimeIdForAgentCommand(agent.agentCommand, runtimes), + model: agent.model ?? undefined, + provider: agent.provider ?? undefined, + // namePool is persona-only; start empty so the user fills it here. + namePool: [], + envVars: agent.envVars ?? {}, + }, + }; +} + export function editPersonaDialogState( persona: AgentPersona, ): PersonaDialogState { diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 9ea9fc258..bd1e77026 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -1,3 +1,7 @@ +// Naming boundary: user-facing copy says "persona template" (the reusable +// setup users save and reuse), while the backend type/storage stays `persona` +// (kind:30175, builtin:* ids, .persona.md). Do NOT rename backend symbols to +// match the UI — the two names map across this boundary intentionally. export const personaLibraryCopy = { title: "My agents", description: 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/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 76fd78b3c..369195a51 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -20,6 +20,7 @@ import { isSingleItemFile } from "@/shared/lib/fileMagic"; import type { AgentPersona, CreatePersonaInput, + ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; import { @@ -27,6 +28,7 @@ import { duplicatePersonaDialogState, editPersonaDialogState, importPersonaDialogState, + saveAsPersonaTemplateDialogState, type PersonaDialogState, } from "./personaDialogState"; import { usePersonaImportActions } from "./usePersonaImportActions"; @@ -203,6 +205,17 @@ export function usePersonaActions() { setPersonaDialogState(duplicatePersonaDialogState(persona)); } + function openSaveAsTemplate(agent: ManagedAgent) { + clearFeedback("library"); + setShouldLoadAcpRuntimes(true); + // Reverse-map against whatever runtimes are already cached; the dialog + // refines once the lazy query resolves. Best-effort — falls back to the + // default runtime when no match is available yet. + setPersonaDialogState( + saveAsPersonaTemplateDialogState(agent, acpRuntimesQuery.data ?? []), + ); + } + function openCatalog() { clearFeedback("catalog"); setIsCatalogDialogOpen(true); @@ -252,6 +265,7 @@ export function usePersonaActions() { openCreate, openEdit, openDuplicate, + openSaveAsTemplate, openCatalog, openDelete, clearFeedback, diff --git a/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts new file mode 100644 index 000000000..716359e4f --- /dev/null +++ b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts @@ -0,0 +1,90 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + useAcpRuntimesQuery, + useCreatePersonaMutation, +} from "@/features/agents/hooks"; +import type { + CreatePersonaInput, + ManagedAgent, + UpdatePersonaInput, +} from "@/shared/api/types"; +import { + saveAsPersonaTemplateDialogState, + type PersonaDialogState, +} from "./personaDialogState"; + +/** + * Self-contained "Save as persona template" flow for surfaces outside the + * Agents page (e.g. the sidebar agent profile) that don't already host + * `usePersonaActions`. Opens the shared `PersonaDialog` prefilled from an + * agent and creates a backend persona on submit — no new backend or IPC. + * + * "Persona template" is the UI name for what the backend calls a `persona` + * (kind:30175); this hook produces a `CreatePersonaInput`. + */ +export function useSaveAsPersonaTemplate() { + const [dialogState, setDialogState] = + React.useState(null); + // Only fetch runtimes once the user actually opens the dialog. + const [shouldLoadRuntimes, setShouldLoadRuntimes] = React.useState(false); + const acpRuntimesQuery = useAcpRuntimesQuery({ enabled: shouldLoadRuntimes }); + const createPersonaMutation = useCreatePersonaMutation(); + + const open = React.useCallback( + (agent: ManagedAgent) => { + setShouldLoadRuntimes(true); + setDialogState( + saveAsPersonaTemplateDialogState(agent, acpRuntimesQuery.data ?? []), + ); + }, + [acpRuntimesQuery.data], + ); + + const close = React.useCallback(() => { + setDialogState(null); + }, []); + + const handleSubmit = React.useCallback( + async (input: CreatePersonaInput | UpdatePersonaInput) => { + // The save-as flow only ever produces a create input. + if ("id" in input) return; + try { + await createPersonaMutation.mutateAsync(input); + toast.success(`Saved ${input.displayName} as a persona template.`); + setDialogState(null); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to save persona template.", + ); + } + }, + [createPersonaMutation], + ); + + return { + open, + dialogState, + dialogProps: { + open: dialogState !== null, + title: dialogState?.title ?? "", + description: dialogState?.description ?? "", + submitLabel: dialogState?.submitLabel ?? "", + initialValues: dialogState?.initialValues ?? null, + error: + createPersonaMutation.error instanceof Error + ? createPersonaMutation.error + : null, + isPending: createPersonaMutation.isPending, + runtimes: acpRuntimesQuery.data ?? [], + runtimesLoading: acpRuntimesQuery.isLoading, + onOpenChange: (next: boolean) => { + if (!next) close(); + }, + onSubmit: handleSubmit, + }, + }; +} diff --git a/desktop/src/features/profile/ui/ProfileQuickActions.tsx b/desktop/src/features/profile/ui/ProfileQuickActions.tsx new file mode 100644 index 000000000..267354481 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileQuickActions.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +/** + * A single circular quick action (icon + label) in the profile summary's + * action row — e.g. Follow / Message / Edit. + */ +export function ProfileQuickAction({ + active, + disabled, + icon: Icon, + label, + onClick, + testId, +}: { + active?: boolean; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick: () => void; + testId?: string; +}) { + return ( + + ); +} + +/** + * A quick action styled to match `ProfileQuickAction` but rendered as a + * `forwardRef` button so it can be a Radix `DropdownMenuTrigger asChild` + * (which clones the child and injects a ref + `aria-*`/event props). Used for + * the overflow `⋮` that hosts actions like "Save as persona template". + */ +export const ProfileQuickActionTrigger = React.forwardRef< + HTMLButtonElement, + { + ariaLabel: string; + icon: LucideIcon; + label: string; + testId?: string; + } & React.ComponentPropsWithoutRef<"button"> +>(function ProfileQuickActionTrigger( + { ariaLabel, icon: Icon, label, testId, ...props }, + ref, +) { + return ( + + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 335202c22..d5a6c67ca 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -14,6 +14,8 @@ import { import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; +import { PersonaDialog } from "@/features/agents/ui/PersonaDialog"; +import { useSaveAsPersonaTemplate } from "@/features/agents/ui/useSaveAsPersonaTemplate"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { @@ -278,6 +280,17 @@ export function UserProfilePanel({ setEditAgentOpen(true); }, []); + // "Save as persona template" flow for the sidebar profile. Self-contained + // (own dialog + create mutation) so it works outside the Agents page, which + // is where `usePersonaActions` lives. Gated on `canEditAgent` below — only a + // managed agent the viewer owns can be promoted. + const saveAsTemplate = useSaveAsPersonaTemplate(); + const handleSaveAsTemplate = React.useCallback(() => { + if (managedAgent) { + saveAsTemplate.open(managedAgent); + } + }, [managedAgent, saveAsTemplate]); + const handleOpenActivity = React.useCallback(() => { onClose(); onOpenAgentSession?.(pubkey); @@ -389,6 +402,7 @@ export function UserProfilePanel({ handleEditAgent={handleEditAgent} handleMessage={handleMessage} handleOpenActivity={handleOpenActivity} + handleSaveAsTemplate={canEditAgent ? handleSaveAsTemplate : undefined} isBot={isBot} isFollowing={isFollowing} isOwner={viewerIsOwner} @@ -441,6 +455,11 @@ export function UserProfilePanel({ /> ) : null; + // Render unconditionally — the dialog stays closed until `open()` seeds it. + const saveAsTemplateDialog = canEditAgent ? ( + + ) : null; + if (isSplitLayout) { return ( <> @@ -452,6 +471,7 @@ export function UserProfilePanel({ {profileBody}
{editAgentDialog} + {saveAsTemplateDialog} ); } @@ -517,6 +537,7 @@ export function UserProfilePanel({ {profileBody} {editAgentDialog} + {saveAsTemplateDialog} ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 42a3739c3..67f1f7c98 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -3,12 +3,14 @@ import type { LucideIcon } from "lucide-react"; import { Activity, ArrowUpRight, + BookmarkPlus, Brain, ChevronDown, ChevronRight, ChevronUp, Copy, Cpu, + Ellipsis, Fingerprint, Hash, MessageSquare, @@ -42,7 +44,17 @@ import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + ProfileQuickAction, + ProfileQuickActionTrigger, +} from "./ProfileQuickActions"; const RUNTIME_LABELS: Record = { goose: "Goose", @@ -73,6 +85,7 @@ export type ProfileSummaryViewProps = { handleEditAgent: () => void; handleMessage: () => void; handleOpenActivity: () => void; + handleSaveAsTemplate?: () => void; isBot: boolean; isFollowing: boolean; isOwner: boolean | undefined; @@ -108,6 +121,7 @@ export function ProfileSummaryView({ handleEditAgent, handleMessage, handleOpenActivity, + handleSaveAsTemplate, isBot, isFollowing, isOwner, @@ -176,6 +190,7 @@ export function ProfileSummaryView({ canEditAgent={canEditAgent} followMutation={followMutation} onEditAgent={handleEditAgent} + onSaveAsTemplate={handleSaveAsTemplate} isFollowing={isFollowing} onMessage={onOpenDm ? handleMessage : undefined} pubkey={pubkey} @@ -430,6 +445,7 @@ function ProfilePrimaryActions({ followMutation, isFollowing, onEditAgent, + onSaveAsTemplate, onMessage, pubkey, unfollowMutation, @@ -438,6 +454,7 @@ function ProfilePrimaryActions({ followMutation: ReturnType; isFollowing: boolean; onEditAgent: () => void; + onSaveAsTemplate?: () => void; onMessage?: () => void; pubkey: string; unfollowMutation: ReturnType; @@ -454,6 +471,11 @@ function ProfilePrimaryActions({ }); }; + // Keep the quick-action row lean: overflow actions (e.g. "Save as persona + // template") live behind a small ⋮ rather than crowding the row with a + // dedicated button. + const showOverflow = canEditAgent && Boolean(onSaveAsTemplate); + return (
{showFollowAction ? ( @@ -481,57 +503,34 @@ function ProfilePrimaryActions({ testId="user-profile-edit-agent" /> ) : null} + {showOverflow ? ( + + + + + event.preventDefault()} + > + + + Save as persona template + + + + ) : null}
); } -function ProfileQuickAction({ - active, - disabled, - icon: Icon, - label, - onClick, - testId, -}: { - active?: boolean; - disabled?: boolean; - icon: LucideIcon; - label: string; - onClick: () => void; - testId?: string; -}) { - return ( - - ); -} - -// ── Field rows ─────────────────────────────────────────────────────────────── - type ProfileField = { copyValue?: string; /** 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 05ba8e0f2..e1e7715da 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -76,10 +76,7 @@ async function openManagedAgentActions( } 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..64e48550d 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);