From a9e808375a0976492d04ee65692371c8ef6f6c01 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 23 Jun 2026 16:31:31 +0100 Subject: [PATCH 1/6] Simplify agents and teams cards --- .../features/agents/ui/AgentIdentityCard.tsx | 69 ++ .../features/agents/ui/CreateIdentityCard.tsx | 37 + .../agents/ui/IdentityInitialsAvatar.tsx | 59 ++ .../features/agents/ui/TeamIdentityCard.tsx | 180 ++++ .../src/features/agents/ui/TeamsSection.tsx | 268 +++--- .../agents/ui/UnifiedAgentsSection.tsx | 879 ++++++++++++------ .../src/shared/ui/identity-card-skeleton.tsx | 44 + 7 files changed, 1089 insertions(+), 447 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentIdentityCard.tsx create mode 100644 desktop/src/features/agents/ui/CreateIdentityCard.tsx create mode 100644 desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx create mode 100644 desktop/src/features/agents/ui/TeamIdentityCard.tsx create mode 100644 desktop/src/shared/ui/identity-card-skeleton.tsx 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 ( +
+ +
+ ); +} From ae2f3a2e3b702fc3c669b8f723ca512a2dc8f1c6 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 13:39:16 +0100 Subject: [PATCH 2/6] Restore agent card status and logs --- .../features/agents/ui/AgentIdentityCard.tsx | 8 ++ .../features/agents/ui/TeamIdentityCard.tsx | 20 +++- .../agents/ui/UnifiedAgentsSection.tsx | 94 +++++++++++++++++++ desktop/tests/e2e/agents.spec.ts | 5 +- desktop/tests/e2e/mesh-compute.spec.ts | 5 +- desktop/tests/e2e/persona-env-vars.spec.ts | 5 +- desktop/tests/e2e/smoke.spec.ts | 5 +- 7 files changed, 125 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx index a66fb9cd7..8dea118cb 100644 --- a/desktop/src/features/agents/ui/AgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -12,6 +12,7 @@ type AgentIdentityCardProps = { label: string; modelLabel: string; onClick: () => void; + status?: ReactNode; }; export function AgentIdentityCard({ @@ -22,6 +23,7 @@ export function AgentIdentityCard({ label, modelLabel, onClick, + status, }: AgentIdentityCardProps) { const trimmedAvatarUrl = avatarUrl?.trim() || null; @@ -56,6 +58,12 @@ export function AgentIdentityCard({
{actions}
) : null} + {status ? ( +
+ {status} +
+ ) : null} +
{label} diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index 17d129e6f..9255f9282 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { Link, Users } from "lucide-react"; +import { Info, Link, Users } from "lucide-react"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; @@ -28,6 +28,7 @@ export function TeamIdentityCard({ actions, children, dataTestId, + description, isSymlink = false, memberCount, personas, @@ -37,6 +38,7 @@ export function TeamIdentityCard({ version, }: TeamIdentityCardProps) { const footerModelLabel = getTeamFooterModelLabel(personas); + const trimmedDescription = description?.trim(); return ( ) : null} + {trimmedDescription ? ( + + + + + +

{trimmedDescription}

+
+
+ ) : null}
{actions}
diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index a63018443..93ab5509d 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -15,7 +15,9 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, @@ -24,6 +26,7 @@ import type { } from "@/shared/api/types"; 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, @@ -36,6 +39,7 @@ import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { AgentIdentityCard } from "./AgentIdentityCard"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { EditAgentDialog } from "./EditAgentDialog"; +import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; type UnifiedAgentsSectionProps = { actionErrorMessage: string | null; @@ -121,6 +125,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { agentsError, isActionPending, isAgentsLoading, + logContent, + logError, + logLoading, + presenceLoaded, + presenceLookup, + selectedLogAgentPubkey, onAddToChannel, onBulkRemoveStopped, onBulkStopRunning, @@ -170,6 +180,14 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { } 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, @@ -249,6 +267,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { key={group.persona.id} persona={group.persona} personaMenuProps={personaMenuProps} + presenceLoaded={presenceLoaded} + presenceLookup={presenceLookup} onOpenAgentProfile={onOpenAgentProfile} /> ); @@ -270,6 +290,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} groupKey="__additional_persona_agents__" label="Additional agent instances" + presenceLoaded={presenceLoaded} + presenceLookup={presenceLookup} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} /> @@ -281,6 +303,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} groupKey="__unknown__" label="Unknown Persona" + presenceLoaded={presenceLoaded} + presenceLookup={presenceLookup} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} /> @@ -292,10 +316,25 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { collapsed={collapsed} groupKey="__ungrouped__" label="Custom Agents" + presenceLoaded={presenceLoaded} + presenceLookup={presenceLookup} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} /> ) : null} + {selectedLogAgent ? ( +
+ +
+ ) : null}
) : null} @@ -371,12 +410,16 @@ function AgentPersonaCard({ 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; @@ -408,6 +451,15 @@ function AgentPersonaCard({ } personaMenuProps.onEditPersona(persona); }} + status={ + agent ? ( + + ) : null + } /> ); } @@ -415,10 +467,14 @@ function AgentPersonaCard({ function StandaloneAgentCard({ agent, agentMenuProps, + presenceLoaded, + presenceLookup, onOpenAgentProfile, }: { agent: ManagedAgent; agentMenuProps: AgentMenuProps; + presenceLoaded: boolean; + presenceLookup: PresenceLookup; onOpenAgentProfile?: (pubkey: string) => void; }) { const title = agent.name; @@ -439,6 +495,35 @@ function StandaloneAgentCard({ 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} /> ); } @@ -465,6 +550,9 @@ function AgentPersonaActionsMenu({
); diff --git a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx index 71fa31065..0381ebc03 100644 --- a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx +++ b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx @@ -34,16 +34,16 @@ export function IdentityInitialsAvatar({ 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))); + const textSizeClassName = size >= 80 ? "text-3xl" : "text-xl"; return ( {initials.length > 0 ? initials : } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 22d6b0d18..7c95dadeb 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -16,8 +16,10 @@ import { import { toast } from "sonner"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { ModelPicker } from "@/features/agents/ui/ModelPicker"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, @@ -390,6 +392,9 @@ function AgentPersonaCard({ 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) { @@ -441,6 +448,7 @@ function StandaloneAgentCard({ }) { const title = agent.name; const profileQuery = useUserProfileQuery(agent.pubkey); + const friendlyError = friendlyAgentLastError(agent.lastError)?.copy; return ( } modelLabel={formatAgentModelLabel(agent.model)} onClick={() => { if (onOpenAgentProfile) { From d2da355455298a6e23be6591bc004f0fded8e969 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 19:20:38 +0100 Subject: [PATCH 5/6] Guard agent card click fallbacks --- desktop/src/features/agents/ui/UnifiedAgentsSection.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 7c95dadeb..cdce92757 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -418,7 +418,9 @@ function AgentPersonaCard({ onOpenAgentProfile(agent.pubkey); return; } - personaMenuProps.onEditPersona(persona); + if (!persona.isBuiltIn) { + personaMenuProps.onEditPersona(persona); + } }} status={ agent ? ( @@ -463,7 +465,7 @@ function StandaloneAgentCard({ onClick={() => { if (onOpenAgentProfile) { onOpenAgentProfile(agent.pubkey); - } else { + } else if (agent.backend.type === "local") { agentMenuProps.onOpenLogs(agent.pubkey); } }} From d7fd6f16279a7f86832a59289940aad1abe4ec77 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Fri, 26 Jun 2026 00:47:33 -0700 Subject: [PATCH 6/6] feat(agents): add "save as persona template" to agent card + sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in promote of an existing managed agent into a reusable persona template. Lands on two surfaces — the agent card overflow menu (standalone agents only) and the sidebar agent profile's overflow menu — sharing one `saveAsPersonaTemplateDialogState` + the existing PersonaDialog and create-persona mutation. No new backend or IPC. The promote is near-lossless: name, system prompt, model, provider, and env vars copy across; the resolved harness command reverse-maps to a runtime id; namePool starts empty for the user to fill in the dialog. UI says "persona template" while the backend stays `persona` (kind:30175, builtin:*, .persona.md) — a boundary comment in personaLibraryCopy.ts records the mapping so the names are not conflated. Pure refactors to stay under the 1000-line file limit: extract AgentActionItems (+ AgentMenuProps) and the profile quick-action buttons into sibling files. Adds regression tests for the dialog-state mapping and a guard that default agent create stays persona-less. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/agents/ui/AgentActionItems.tsx | 155 ++++++++++++++++++ desktop/src/features/agents/ui/AgentsView.tsx | 1 + .../agents/ui/UnifiedAgentsSection.tsx | 129 +-------------- .../agents/ui/personaDialogState.test.mjs | 128 +++++++++++++++ .../features/agents/ui/personaDialogState.ts | 55 +++++++ .../features/agents/ui/personaLibraryCopy.ts | 4 + .../features/agents/ui/usePersonaActions.ts | 14 ++ .../agents/ui/useSaveAsPersonaTemplate.ts | 90 ++++++++++ .../profile/ui/ProfileQuickActions.tsx | 88 ++++++++++ .../features/profile/ui/UserProfilePanel.tsx | 21 +++ .../profile/ui/UserProfilePanelSections.tsx | 93 ++++++----- 11 files changed, 608 insertions(+), 170 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentActionItems.tsx create mode 100644 desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts create mode 100644 desktop/src/features/profile/ui/ProfileQuickActions.tsx 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/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/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index cdce92757..cc3efbec4 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -7,13 +7,8 @@ import { FileText, OctagonX, Pencil, - Play, - Power, - Square, Trash2, - UserPlus, } from "lucide-react"; -import { toast } from "sonner"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; @@ -38,6 +33,7 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; 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"; @@ -65,6 +61,7 @@ type UnifiedAgentsSectionProps = { 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; @@ -110,6 +107,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onCreateAgent, onDeleteAgent, onOpenAgentProfile, + onSaveAsTemplate, onSelectLogAgent, onStartAgent, onStopAgent, @@ -187,6 +185,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onAddToChannel, onDelete: onDeleteAgent, onOpenLogs: onSelectLogAgent, + onSaveAsTemplate, onStart: onStartAgent, onStop: onStopAgent, onToggleStartOnAppLaunch, @@ -349,16 +348,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); } -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; @@ -614,6 +603,7 @@ function AgentActionsMenu({ onAddToChannel, onDelete, onOpenLogs, + onSaveAsTemplate, onStart, onStop, onToggleStartOnAppLaunch, @@ -643,6 +633,7 @@ function AgentActionsMenu({ onAddToChannel={onAddToChannel} onDelete={onDelete} onOpenLogs={onOpenLogs} + onSaveAsTemplate={onSaveAsTemplate} onStart={onStart} onStop={onStop} onToggleStartOnAppLaunch={onToggleStartOnAppLaunch} @@ -660,114 +651,6 @@ function AgentActionsMenu({ ); } -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"; 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/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; /**