diff --git a/desktop/src/features/agents/ui/AgentGroupRows.tsx b/desktop/src/features/agents/ui/AgentGroupRows.tsx index 2aa95372e..a8131906c 100644 --- a/desktop/src/features/agents/ui/AgentGroupRows.tsx +++ b/desktop/src/features/agents/ui/AgentGroupRows.tsx @@ -6,7 +6,6 @@ export type AgentGroupRowsProps = { agents: ManagedAgent[]; channelIdToName: Record; channelsByPubkey: Record; - isActionPending: boolean; logContent: string | null; logError: Error | null; logLoading: boolean; @@ -14,19 +13,14 @@ export type AgentGroupRowsProps = { presenceLoaded: boolean; presenceLookup: PresenceLookup; selectedLogAgentPubkey: string | null; - onAddToChannel: (agent: ManagedAgent) => void; - onDelete: (pubkey: string) => void; + onOpenProfile: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; - onStart: (pubkey: string) => void; - onStop: (pubkey: string) => void; - onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; }; export function AgentGroupRows({ agents, channelIdToName, channelsByPubkey, - isActionPending, logContent, logError, logLoading, @@ -34,12 +28,8 @@ export function AgentGroupRows({ presenceLoaded, presenceLookup, selectedLogAgentPubkey, - onAddToChannel, - onDelete, + onOpenProfile, onSelectLogAgent, - onStart, - onStop, - onToggleStartOnAppLaunch, }: AgentGroupRowsProps) { return (
@@ -48,7 +38,6 @@ export function AgentGroupRows({ agent={agent} channelIdToName={channelIdToName} channelNames={channelsByPubkey[normalizePubkey(agent.pubkey)] ?? []} - isActionPending={isActionPending} isLogSelected={selectedLogAgentPubkey === agent.pubkey} key={agent.pubkey} logContent={ @@ -59,12 +48,8 @@ export function AgentGroupRows({ personaLabelsById={personaLabelsById} presenceLoaded={presenceLoaded} presenceLookup={presenceLookup} - onAddToChannel={onAddToChannel} - onDelete={onDelete} + onOpenProfile={onOpenProfile} onSelectLogAgent={onSelectLogAgent} - onStart={onStart} - onStop={onStop} - onToggleStartOnAppLaunch={onToggleStartOnAppLaunch} /> ))}
diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 9bcedca67..dadb07f31 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -1,5 +1,12 @@ import * as React from "react"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { AgentPersona } from "@/shared/api/types"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const AgentsView = React.lazy(async () => { @@ -7,12 +14,63 @@ const AgentsView = React.lazy(async () => { return { default: module.AgentsView }; }); +type ProfilePanelTarget = + | { kind: "pubkey"; pubkey: string } + | { kind: "persona"; persona: AgentPersona }; + export function AgentsScreen() { + const identityQuery = useIdentityQuery(); + const [profilePanelTarget, setProfilePanelTarget] = + React.useState(null); + const threadPanelWidth = useThreadPanelWidth(); + const openDmMutation = useOpenDmMutation(); + const { goChannel } = useAppNavigation(); + + const handleOpenDm = React.useCallback( + async (pubkeys: string[]) => { + const dm = await openDmMutation.mutateAsync({ pubkeys }); + await goChannel(dm.id); + }, + [goChannel, openDmMutation], + ); + return ( -
- }> - - -
+ + setProfilePanelTarget({ kind: "persona", persona }) + } + onOpenProfilePanel={(pubkey) => + setProfilePanelTarget({ kind: "pubkey", pubkey }) + } + > +
+
+ }> + + + {profilePanelTarget ? ( + setProfilePanelTarget(null)} + onOpenDm={handleOpenDm} + onResetWidth={threadPanelWidth.onResetWidth} + onResizeStart={threadPanelWidth.onResizeStart} + persona={ + profilePanelTarget.kind === "persona" + ? profilePanelTarget.persona + : undefined + } + pubkey={ + profilePanelTarget.kind === "pubkey" + ? profilePanelTarget.pubkey + : undefined + } + widthPx={threadPanelWidth.widthPx} + /> + ) : null} +
+
+
); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 639bd3569..9e5216acf 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -19,8 +19,10 @@ import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; export function AgentsView() { + const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); const agents = useManagedAgentActions(); const personas = usePersonaActions(); const teamActions = useTeamActions( @@ -75,11 +77,6 @@ export function AgentsView() { personaLabelsById={personas.personaLabelsById} presenceLoaded={agents.managedPresenceQuery.isSuccess} presenceLookup={agents.managedPresenceQuery.data ?? {}} - onAddToChannel={(agent) => { - agents.setActionNoticeMessage(null); - agents.setActionErrorMessage(null); - agents.setAgentToAddToChannel(agent); - }} onBulkRemoveStopped={() => { void agents.handleBulkRemoveStopped(); }} @@ -89,22 +86,13 @@ export function AgentsView() { onCreateAgent={() => { agents.setIsCreateOpen(true); }} - onDeleteAgent={(pubkey) => { - void agents.handleDelete(pubkey); - }} - onSelectLogAgent={agents.setLogAgentPubkey} - onStartAgent={(pubkey) => { - void agents.handleStart(pubkey); - }} - onStopAgent={(pubkey) => { - void agents.handleStop(pubkey); + onOpenAgentProfile={(pubkey) => { + openProfilePanel?.(pubkey); }} - onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => { - void agents.handleToggleStartOnAppLaunch( - pubkey, - startOnAppLaunch, - ); + onOpenPersonaProfile={(persona) => { + openPersonaProfilePanel?.(persona); }} + onSelectLogAgent={agents.setLogAgentPubkey} selectedLogAgentPubkey={agents.logAgentPubkey} // Persona props canChooseCatalog={personas.catalogPersonas.length > 0} @@ -128,13 +116,6 @@ export function AgentsView() { isPersonasPending={personas.isPending} onCreatePersona={personas.openCreate} onChooseCatalog={personas.openCatalog} - onDuplicatePersona={personas.openDuplicate} - onEditPersona={personas.openEdit} - onExportPersona={personas.handleExport} - onDeactivatePersona={(persona) => { - void personas.handleSetActive(persona, false, "library"); - }} - onDeletePersona={personas.openDelete} onImportPersonaFile={(fileBytes, fileName) => { void personas.handleImportFile(fileBytes, fileName); }} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 556f83a72..a070d5bd3 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -1,19 +1,6 @@ import * as React from "react"; -import { - ChevronDown, - ChevronRight, - Clipboard, - Ellipsis, - FileText, - Pencil, - Play, - Power, - Square, - Trash2, - UserPlus, -} from "lucide-react"; -import { toast } from "sonner"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -28,24 +15,15 @@ import type { PresenceStatus, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; -import { EditAgentDialog } from "./EditAgentDialog"; +import { Button } from "@/shared/ui/button"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; -import { ModelPicker } from "./ModelPicker"; import { truncatePubkey } from "./agentUi"; export function ManagedAgentRow({ agent, channelIdToName, channelNames, - isActionPending, isLogSelected, logContent, logError, @@ -53,17 +31,12 @@ export function ManagedAgentRow({ personaLabelsById, presenceLoaded, presenceLookup, - onAddToChannel, - onDelete, + onOpenProfile, onSelectLogAgent, - onStart, - onStop, - onToggleStartOnAppLaunch, }: { agent: ManagedAgent; channelIdToName: Record; channelNames: { id: string; name: string }[]; - isActionPending: boolean; isLogSelected: boolean; logContent: string | null; logError: Error | null; @@ -71,14 +44,9 @@ export function ManagedAgentRow({ personaLabelsById: Record; presenceLoaded: boolean; presenceLookup: PresenceLookup; - onAddToChannel: (agent: ManagedAgent) => void; - onDelete: (pubkey: string) => void; + onOpenProfile: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; - onStart: (pubkey: string) => void; - onStop: (pubkey: string) => void; - onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; }) { - const isActive = agent.status === "running" || agent.status === "deployed"; const isLocal = agent.backend.type === "local"; const runtimeSource = agent.backend.type === "provider" ? `Remote (${agent.backend.id})` : null; @@ -180,18 +148,14 @@ export function ManagedAgentRow({ )}
- - onSelectLogAgent(pubkey)} - onStart={onStart} - onStop={onStop} - onToggleStartOnAppLaunch={onToggleStartOnAppLaunch} - /> +
@@ -401,149 +365,6 @@ function RuntimeBlock({ ); } -function AgentActionsMenu({ - agent, - isActionPending, - isActive, - onAddToChannel, - onDelete, - onOpenLogs, - onStart, - onStop, - onToggleStartOnAppLaunch, -}: { - agent: ManagedAgent; - isActionPending: boolean; - isActive: 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; -}) { - const [editOpen, setEditOpen] = React.useState(false); - - return ( - <> - - - - - event.preventDefault()} - > - {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" ? ( - setEditOpen(true)}> - - Edit - - ) : 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 AgentOriginBadge({ agent }: { agent: ManagedAgent }) { return ( diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index eede54956..eceb9d3e2 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -28,7 +28,6 @@ import { } 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"; @@ -47,15 +46,12 @@ type UnifiedAgentsSectionProps = { personaLabelsById: Record; presenceLoaded: boolean; presenceLookup: PresenceLookup; - onAddToChannel: (agent: ManagedAgent) => void; onBulkRemoveStopped: () => void; onBulkStopRunning: () => void; onCreateAgent: () => void; - onDeleteAgent: (pubkey: string) => void; + onOpenAgentProfile: (pubkey: string) => void; + onOpenPersonaProfile: (persona: AgentPersona) => void; onSelectLogAgent: (pubkey: string | null) => void; - onStartAgent: (pubkey: string) => void; - onStopAgent: (pubkey: string) => void; - onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; selectedLogAgentPubkey: string | null; canChooseCatalog: boolean; personas: AgentPersona[]; @@ -66,11 +62,6 @@ type UnifiedAgentsSectionProps = { isPersonasPending: boolean; onCreatePersona: () => void; onChooseCatalog: () => void; - onDuplicatePersona: (persona: AgentPersona) => void; - onEditPersona: (persona: AgentPersona) => void; - onExportPersona: (persona: AgentPersona) => void; - onDeactivatePersona: (persona: AgentPersona) => void; - onDeletePersona: (persona: AgentPersona) => void; onImportPersonaFile: (fileBytes: number[], fileName: string) => void; }; @@ -120,15 +111,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { personaLabelsById, presenceLoaded, presenceLookup, - onAddToChannel, onBulkRemoveStopped, onBulkStopRunning, onCreateAgent, - onDeleteAgent, + onOpenAgentProfile, + onOpenPersonaProfile, onSelectLogAgent, - onStartAgent, - onStopAgent, - onToggleStartOnAppLaunch, selectedLogAgentPubkey, canChooseCatalog, personas, @@ -139,11 +127,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPersonasPending, onCreatePersona, onChooseCatalog, - onDuplicatePersona, - onEditPersona, - onExportPersona, - onDeactivatePersona, - onDeletePersona, onImportPersonaFile, } = props; @@ -188,12 +171,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { presenceLoaded, presenceLookup, selectedLogAgentPubkey, - onAddToChannel, - onDelete: onDeleteAgent, + onOpenProfile: onOpenAgentProfile, onSelectLogAgent, - onStart: onStartAgent, - onStop: onStopAgent, - onToggleStartOnAppLaunch, } as const; return ( @@ -277,16 +256,15 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ) : !hasAgents ? ( Inactive ) : null} - + {!isCollapsed && hasAgents ? ( diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx new file mode 100644 index 000000000..dfd69b5aa --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -0,0 +1,105 @@ +import type { LucideIcon } from "lucide-react"; +import { CopyPlus, Download, Trash2 } from "lucide-react"; + +import type { ManagedAgent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +export function UserProfileAgentActions({ + isPending, + managedAgent, + onDelete, + onDuplicatePersona, + onExportPersona, + personaActionKey, +}: { + isPending: boolean; + managedAgent?: ManagedAgent; + onDelete?: () => void; + onDuplicatePersona?: () => void; + onExportPersona?: () => void; + personaActionKey?: string; +}) { + const actionKey = managedAgent?.pubkey ?? "persona-draft"; + const personaKey = personaActionKey ?? actionKey; + + return ( +
+ {onDuplicatePersona ? ( + + ) : null} + {onExportPersona ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
+ ); +} + +function AgentActionRow({ + destructive, + disabled, + icon: Icon, + label, + onClick, + testId, + trailing, +}: { + destructive?: boolean; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick: () => void; + testId: string; + trailing?: string; +}) { + return ( + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 930e73a94..fd11feca3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { ArrowLeft, X } from "lucide-react"; +import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { @@ -8,12 +9,41 @@ import { } from "@/features/agent-memory/hooks"; import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; import { + type AttachManagedAgentToChannelResult, + useAcpRuntimesQuery, + useAvailableAcpRuntimes, + useCreateManagedAgentMutation, + useCreatePersonaMutation, + useDeleteManagedAgentMutation, + useDeletePersonaMutation, + useExportPersonaJsonMutation, + useManagedAgentLogQuery, useRelayAgentsQuery, useManagedAgentsQuery, + usePersonasQuery, + useSetManagedAgentStartOnAppLaunchMutation, + useSetPersonaActiveMutation, + useStartManagedAgentMutation, + useStopManagedAgentMutation, + useUpdateManagedAgentMutation, + useUpdatePersonaMutation, } from "@/features/agents/hooks"; +import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog"; import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { + isManagedAgentActive, + startManagedAgentWithRules, + stopManagedAgentWithRules, +} from "@/features/agents/lib/managedAgentControlActions"; +import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; +import { + duplicatePersonaDialogState, + editPersonaDialogState, + type PersonaDialogState, +} from "@/features/agents/ui/personaDialogState"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { @@ -24,10 +54,30 @@ import { useUserProfileQuery, } from "@/features/profile/hooks"; import { + AgentInfoFocusedView, + AgentInstructionFocusedView, + AgentSettingsFocusedView, ChannelsFocusedView, + DiagnosticsFocusedView, MemoryFocusedView, + ModelFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; +import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; +import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; +import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit"; +import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs"; +import { + deriveProfileChannels, + PROFILE_PANEL_VIEW_TITLES, + type ProfilePanelView, + resolveAgentInstruction, + resolveOwnerHandle, + resolvePanelProfile, + resolveProfileDisplayName, + type UserProfilePanelProps, + useRetainedPersona, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; @@ -40,7 +90,13 @@ import { auxiliaryPanelContentPaddingClass, } from "@/shared/layout/AuxiliaryPanelHeader"; import { cn } from "@/shared/lib/cn"; -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { + AgentPersona, + Channel, + CreateManagedAgentInput, + CreatePersonaInput, + UpdatePersonaInput, +} from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { OverlayPanelBackdrop, @@ -49,85 +105,7 @@ import { PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; -type UserProfilePanelProps = { - canResetWidth?: boolean; - currentPubkey?: string; - isSinglePanelView?: boolean; - layout?: "standalone" | "split"; - onClose: () => void; - onOpenDm?: (pubkeys: string[]) => void; - onResetWidth?: () => void; - onResizeStart?: (event: React.PointerEvent) => void; - onViewChange: ( - view: ProfilePanelView, - options?: { replace?: boolean }, - ) => void; - pubkey: string; - /** - * When true, the panel sits beside a sibling pane managed by a single-panel - * width controller (ChannelScreen). The width is clamped so the sibling keeps - * at least THREAD_PANEL_MIN_WIDTH_PX. Standalone/floating mounts (e.g. Pulse) - * have no such sibling, so they omit this and use the configured width - * directly — otherwise `calc(100% - 300px)` would wrongly shrink the panel. - */ - splitPaneClamp?: boolean; - view: ProfilePanelView; - widthPx: number; -}; - -export type ProfilePanelView = "summary" | "memories" | "channels"; - -const VIEW_TITLES: Record = { - summary: "Profile", - memories: "Memories", - channels: "Channels", -}; - -function truncatePubkey(pubkey: string) { - if (pubkey.length <= 16) { - return pubkey; - } - - return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; -} - -type ProfileChannelLink = { - id: string; - name: string; -}; - -function deriveProfileChannels( - pubkeyLower: string, - relayAgent: RelayAgent | undefined, - managedAgent: ManagedAgent | undefined, - channels: Channel[] | undefined, -): ProfileChannelLink[] { - const links = new Map(); - const channelsByName = new Map( - channels?.map((channel) => [channel.name, channel]) ?? [], - ); - - relayAgent?.channels.forEach((name, index) => { - const channel = channelsByName.get(name); - const id = relayAgent.channelIds[index] ?? channel?.id ?? name; - links.set(id, { id, name }); - }); - - if (managedAgent && channels) { - for (const channel of channels) { - const isMember = channel.memberPubkeys.some( - (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, - ); - if (isMember) { - links.set(channel.id, { id: channel.id, name: channel.name }); - } - } - } - - return [...links.values()].sort((left, right) => - left.name.localeCompare(right.name), - ); -} +export type { ProfilePanelView }; export function UserProfilePanel({ canResetWidth, @@ -139,9 +117,10 @@ export function UserProfilePanel({ onResetWidth, onResizeStart, onViewChange, + persona, pubkey, splitPaneClamp = false, - view, + view: controlledView, widthPx, }: UserProfilePanelProps) { const isOverlay = useIsThreadPanelOverlay(); @@ -149,41 +128,125 @@ export function UserProfilePanel({ const isSplitLayout = layout === "split"; useEscapeKey(onClose, isOverlay || isSinglePanelView); + const [internalView, setInternalView] = + React.useState("summary"); + const view = controlledView ?? internalView; + const setView = React.useCallback( + (nextView: ProfilePanelView, options?: { replace?: boolean }) => { + if (onViewChange) { + onViewChange(nextView, options); + return; + } + setInternalView(nextView); + }, + [onViewChange], + ); const [editAgentOpen, setEditAgentOpen] = React.useState(false); + const [addToChannelOpen, setAddToChannelOpen] = React.useState(false); + const [personaDialogState, setPersonaDialogState] = + React.useState(null); + const [personaToDelete, setPersonaToDelete] = + React.useState(null); - const profileQuery = useUserProfileQuery(pubkey); + const personasQuery = usePersonasQuery(); + const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + const managedAgent = React.useMemo(() => { + const agents = managedAgentsQuery.data ?? []; + if (pubkey) { + const pubkeyLower = pubkey.toLowerCase(); + return agents.find((agent) => agent.pubkey.toLowerCase() === pubkeyLower); + } + if (persona) { + return agents.find((agent) => agent.personaId === persona.id); + } + return undefined; + }, [managedAgentsQuery.data, persona, pubkey]); + const resolvedPersonaFromSource = React.useMemo(() => { + const personaId = persona?.id ?? managedAgent?.personaId; + if (personaId) { + const refreshedPersona = personasQuery.data?.find( + (candidate) => candidate.id === personaId, + ); + if (refreshedPersona) { + return refreshedPersona; + } + } + if (persona) { + return persona; + } + if (!managedAgent?.personaId) { + return undefined; + } + return personasQuery.data?.find( + (candidate) => candidate.id === managedAgent.personaId, + ); + }, [managedAgent?.personaId, persona, personasQuery.data]); + const profileIdentityKey = + pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`; + const resolvedPersona = useRetainedPersona( + resolvedPersonaFromSource, + profileIdentityKey, + ); + const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null; + const pubkeyLower = effectivePubkey?.toLowerCase() ?? ""; + + const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined); const currentProfileQuery = useProfileQuery(currentPubkey !== undefined); - // Batch avatar prefetch seeds kind:0 summaries without `about`; refetch on open - // so the hero can show the full profile description from relay. React.useEffect(() => { + if (!effectivePubkey) return; void profileQuery.refetch(); - }, [profileQuery.refetch]); + }, [effectivePubkey, profileQuery.refetch]); const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); - const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + const availableRuntimesQuery = useAvailableAcpRuntimes(); + const acpRuntimesQuery = useAcpRuntimesQuery(); + const createAgentMutation = useCreateManagedAgentMutation(); + const updateManagedAgentMutation = useUpdateManagedAgentMutation(); + const startAgentMutation = useStartManagedAgentMutation(); + const stopAgentMutation = useStopManagedAgentMutation(); + const deleteAgentMutation = useDeleteManagedAgentMutation(); + const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); + const createPersonaMutation = useCreatePersonaMutation(); + const updatePersonaMutation = useUpdatePersonaMutation(); + const deletePersonaMutation = useDeletePersonaMutation(); + const setPersonaActiveMutation = useSetPersonaActiveMutation(); + const exportPersonaJsonMutation = useExportPersonaJsonMutation(); const channelsQuery = useChannelsQuery(); - const presenceQuery = usePresenceQuery([pubkey]); - const userStatusQuery = useUserStatusQuery([pubkey]); + const presenceQuery = usePresenceQuery( + effectivePubkey ? [effectivePubkey] : [], + ); + const userStatusQuery = useUserStatusQuery( + effectivePubkey ? [effectivePubkey] : [], + ); const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); const { onOpenAgentSession } = useAgentSession(); const { goChannel } = useAppNavigation(); - - const profile = profileQuery.data; - const pubkeyLower = pubkey.toLowerCase(); - const presenceStatus = presenceQuery.data?.[pubkeyLower]; - const userStatus = userStatusQuery.data?.[pubkeyLower]; + const profile = resolvePanelProfile({ + managedAgent, + persona: resolvedPersona, + profile: profileQuery.data, + }); + const presenceStatus = pubkeyLower + ? presenceQuery.data?.[pubkeyLower] + : undefined; + const userStatus = pubkeyLower + ? userStatusQuery.data?.[pubkeyLower] + : undefined; const relayAgent = relayAgentsQuery.data?.find( (agent) => agent.pubkey.toLowerCase() === pubkeyLower, ); - const managedAgent = managedAgentsQuery.data?.find( - (agent) => agent.pubkey.toLowerCase() === pubkeyLower, + const managedAgentLogQuery = useManagedAgentLogQuery( + view === "logs" && managedAgent?.backend.type === "local" + ? managedAgent.pubkey + : null, ); - const isBot = Boolean(relayAgent || managedAgent); - const isOwner = useIsManagedAgent(isBot ? pubkey : null); + const isBot = Boolean(relayAgent || managedAgent || resolvedPersona); + const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null); + const isOwner = resolvedPersona ? true : managedAgentOwner; // Populate the active-turns store for this agent so useActiveAgentTurns works // even if the Agents page hasn't been visited yet. @@ -196,15 +259,40 @@ export function UserProfilePanel({ ); useActiveAgentTurnsBridge(bridgeAgents); useManagedAgentObserverBridge(bridgeAgents); - const canEditAgent = isOwner === true && managedAgent !== undefined; - const memoryQuery = useAgentMemoryQuery(pubkey, { - enabled: isOwner === true, + const canEditAgent = + isOwner === true && + (managedAgent !== undefined || + (resolvedPersona !== undefined && !resolvedPersona.isBuiltIn)); + const memoryQuery = useAgentMemoryQuery(effectivePubkey, { + enabled: isOwner === true && Boolean(effectivePubkey), }); const isSelf = - currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); - const canViewActivity = isOwner === true && Boolean(onOpenAgentSession); + currentPubkey !== undefined && + pubkeyLower.length > 0 && + pubkeyLower === currentPubkey.toLowerCase(); + const canViewActivity = + isOwner === true && Boolean(onOpenAgentSession) && Boolean(effectivePubkey); + const canOpenAgentLogs = + isOwner === true && managedAgent?.backend.type === "local"; + const canInstantiateAgent = + isOwner === true && + resolvedPersona !== undefined && + managedAgent === undefined; + const isAgentActionPending = + createAgentMutation.isPending || + updateManagedAgentMutation.isPending || + startAgentMutation.isPending || + stopAgentMutation.isPending || + deleteAgentMutation.isPending || + startOnLaunchMutation.isPending || + createPersonaMutation.isPending || + updatePersonaMutation.isPending || + deletePersonaMutation.isPending || + setPersonaActiveMutation.isPending || + exportPersonaJsonMutation.isPending; const isFollowing = !isSelf && + pubkeyLower.length > 0 && (contactListQuery.data?.contacts.some( (contact) => contact.pubkey.toLowerCase() === pubkeyLower, ) ?? @@ -229,19 +317,326 @@ export function UserProfilePanel({ return map; }, [channelsQuery.data]); + const targetKey = + effectivePubkey ?? `persona:${resolvedPersona?.id ?? "unknown"}`; + const prevTargetKeyRef = React.useRef(targetKey); + React.useEffect(() => { + if (prevTargetKeyRef.current === targetKey) return; + prevTargetKeyRef.current = targetKey; + setView("summary", { replace: true }); + }, [setView, targetKey]); const handleMessage = React.useCallback(() => { - onOpenDm?.([pubkey]); + if (!effectivePubkey) return; + onOpenDm?.([effectivePubkey]); onClose(); - }, [onClose, onOpenDm, pubkey]); + }, [effectivePubkey, onClose, onOpenDm]); const handleEditAgent = React.useCallback(() => { + if (resolvedPersona && !resolvedPersona.isBuiltIn) { + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + return; + } setEditAgentOpen(true); - }, []); + }, [resolvedPersona]); + + const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = + useProfileAgentDeletion({ + channels: channelsQuery.data, + deleteManagedAgent: deleteAgentMutation.mutateAsync, + managedAgent, + managedAgents: managedAgentsQuery.data, + presenceLookup: presenceQuery.data, + relayAgents: relayAgentsQuery.data, + }); + + const createManagedAgentForPersona = React.useCallback( + async (personaToStart: AgentPersona) => { + const runtimes = availableRuntimesQuery.data ?? []; + const defaultRuntime = runtimes[0] ?? null; + const { runtime, warnings } = resolvePersonaRuntime( + personaToStart.runtime, + runtimes, + defaultRuntime, + ); + + for (const warning of warnings) { + toast.warning(warning); + } + + if (!runtime) { + throw new Error("No available runtime found for this agent."); + } + + const input: CreateManagedAgentInput = { + name: personaToStart.displayName, + acpCommand: "buzz-acp", + agentCommand: runtime.command, + agentArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand ?? "", + personaId: personaToStart.id, + systemPrompt: personaToStart.systemPrompt, + avatarUrl: personaToStart.avatarUrl ?? undefined, + model: personaToStart.model ?? undefined, + envVars: personaToStart.envVars, + spawnAfterCreate: true, + startOnAppLaunch: true, + backend: { type: "local" }, + }; + + const created = await createAgentMutation.mutateAsync(input); + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + return created; + }, + [ + availableRuntimesQuery.data, + createAgentMutation.mutateAsync, + managedAgentsQuery.refetch, + relayAgentsQuery.refetch, + ], + ); + + const handleAgentPrimaryAction = React.useCallback(async () => { + if (!managedAgent) return; + + try { + if (isManagedAgentActive(managedAgent)) { + const result = await stopManagedAgentWithRules({ + agent: managedAgent, + channels: channelsQuery.data ?? [], + relayAgents: relayAgentsQuery.data ?? [], + stopManagedAgent: stopAgentMutation.mutateAsync, + }); + toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`); + return; + } + + await startManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent: startAgentMutation.mutateAsync, + }); + toast.success( + managedAgent.backend.type === "provider" + ? `Deploying ${managedAgent.name}.` + : `Started ${managedAgent.name}.`, + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent action failed.", + ); + } + }, [ + channelsQuery.data, + managedAgent, + relayAgentsQuery.data, + startAgentMutation.mutateAsync, + stopAgentMutation.mutateAsync, + ]); + + const handleInstantiateAgent = React.useCallback(async () => { + if (!resolvedPersona) return; + + try { + const created = await createManagedAgentForPersona(resolvedPersona); + if (created.spawnError) { + toast.error(created.spawnError); + } else { + toast.success(`Started ${created.agent.name}.`); + } + if (created.profileSyncError) { + toast.warning(created.profileSyncError); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to start agent.", + ); + } + }, [createManagedAgentForPersona, resolvedPersona]); + + const handleToggleAgentAutoStart = React.useCallback(async () => { + if (managedAgent?.backend.type !== "local") return; + + try { + const updated = await startOnLaunchMutation.mutateAsync({ + pubkey: managedAgent.pubkey, + startOnAppLaunch: !managedAgent.startOnAppLaunch, + }); + toast.success( + updated.startOnAppLaunch + ? `Will start ${updated.name} automatically.` + : `${updated.name} will stay manual-start only.`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update startup preference.", + ); + } + }, [managedAgent, startOnLaunchMutation.mutateAsync]); + + const handleDeleteAgent = React.useCallback(async () => { + if (!managedAgent) return; + + try { + const result = await deleteManagedAgentRecord(managedAgent); + if (result.cancelled) return; + + toast.success(`Deleted ${managedAgent.name}.`); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + }, [deleteManagedAgentRecord, managedAgent, onClose]); + + const handleSubmitPersona = React.useCallback( + async (input: CreatePersonaInput | UpdatePersonaInput) => { + await submitProfilePersonaDialog({ + createManagedAgentForPersona, + createPersona: createPersonaMutation.mutateAsync, + input, + managedAgent, + onDone: () => { + setPersonaDialogState(null); + void personasQuery.refetch(); + }, + previousPersona: resolvedPersona, + runtimes: acpRuntimesQuery.data ?? [], + updateManagedAgent: updateManagedAgentMutation.mutateAsync, + updatePersona: updatePersonaMutation.mutateAsync, + }); + }, + [ + createPersonaMutation.mutateAsync, + createManagedAgentForPersona, + managedAgent, + personasQuery.refetch, + resolvedPersona, + acpRuntimesQuery.data, + updateManagedAgentMutation.mutateAsync, + updatePersonaMutation.mutateAsync, + ], + ); + + const handleEditPersona = React.useCallback(() => { + if (!resolvedPersona || resolvedPersona.isBuiltIn) return; + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + }, [resolvedPersona]); + + const handleDuplicatePersona = React.useCallback(() => { + if (!resolvedPersona) return; + setPersonaDialogState(duplicatePersonaDialogState(resolvedPersona)); + }, [resolvedPersona]); + + const handleExportPersona = React.useCallback(() => { + if (!resolvedPersona) return; + exportPersonaJsonMutation.mutate(resolvedPersona.id, { + onSuccess: (saved) => { + if (saved) { + toast.success(`Exported ${resolvedPersona.displayName}.`); + } + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to export agent.", + ); + }, + }); + }, [exportPersonaJsonMutation, resolvedPersona]); + + const handleDeletePersona = React.useCallback(async () => { + if (!resolvedPersona) return; + + if (resolvedPersona.isBuiltIn) { + try { + const deletedInstances = + await deleteManagedAgentsForPersona(resolvedPersona); + if (deletedInstances.cancelled) return; + + await setPersonaActiveMutation.mutateAsync({ + id: resolvedPersona.id, + active: false, + }); + toast.success(`Removed ${resolvedPersona.displayName} from My Agents.`); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + return; + } + + if (resolvedPersona.sourceTeam) { + toast.error("This agent is managed by a team."); + return; + } + + setPersonaToDelete(resolvedPersona); + }, [ + deleteManagedAgentsForPersona, + onClose, + resolvedPersona, + setPersonaActiveMutation.mutateAsync, + ]); + + const handleConfirmDeletePersona = React.useCallback( + async (personaToConfirm: AgentPersona) => { + if (personaToConfirm.sourceTeam) { + toast.error("This agent is managed by a team."); + setPersonaToDelete(null); + return; + } + + try { + const deletedInstances = + await deleteManagedAgentsForPersona(personaToConfirm); + if (deletedInstances.cancelled) return; + + await deletePersonaMutation.mutateAsync(personaToConfirm.id); + toast.success(`Deleted ${personaToConfirm.displayName}.`); + setPersonaToDelete(null); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + }, + [deleteManagedAgentsForPersona, deletePersonaMutation.mutateAsync, onClose], + ); + + const handleAddedToChannel = React.useCallback( + (channel: Channel, result: AttachManagedAgentToChannelResult) => { + if (result.restarted) { + toast.success( + `Added ${result.agent.name} to ${channel.name} and restarted it.`, + ); + } else if (result.started) { + toast.success(`Added ${result.agent.name} to ${channel.name}.`); + } else if (result.membershipAdded) { + toast.success(`Added ${result.agent.name} to ${channel.name}.`); + } else { + toast.success(`${result.agent.name} is already in ${channel.name}.`); + } + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + void channelsQuery.refetch(); + }, + [ + channelsQuery.refetch, + managedAgentsQuery.refetch, + relayAgentsQuery.refetch, + ], + ); const handleOpenActivity = React.useCallback(() => { + if (!effectivePubkey) return; onClose(); - onOpenAgentSession?.(pubkey); - }, [onClose, onOpenAgentSession, pubkey]); + onOpenAgentSession?.(effectivePubkey); + }, [effectivePubkey, onClose, onOpenAgentSession]); const handleOpenChannel = React.useCallback( (channelId: string) => { @@ -250,24 +645,46 @@ export function UserProfilePanel({ [goChannel], ); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); - const ownerHandle = React.useMemo(() => { - if (currentPubkey === undefined) { - return null; - } - - const currentProfile = currentProfileQuery.data; - return ( - currentProfile?.nip05Handle?.trim() || - currentProfile?.displayName?.trim() || - truncatePubkey(currentPubkey) - ); - }, [currentProfileQuery.data, currentPubkey]); + const displayName = resolveProfileDisplayName({ + persona: resolvedPersona, + profile, + pubkey: effectivePubkey, + }); + const ownerHandle = resolveOwnerHandle( + currentProfileQuery.data, + currentPubkey, + ); const ownerDisplayName = ownerHandle ? `${ownerHandle} (you)` : null; - const panelTitle = VIEW_TITLES[view]; - const memoryCount = memoryQuery.data - ? (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length - : undefined; + const memoryCount = + memoryQuery.data && + (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length; + const agentInstruction = resolveAgentInstruction( + managedAgent, + resolvedPersona, + ); + const canManagePersona = isOwner === true && resolvedPersona !== undefined; + const canEditPersona = + canManagePersona && resolvedPersona?.isBuiltIn !== true; + const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; + const { + agentInfoFields, + agentSettingsFields, + diagnosticsFields, + diagnosticsSummary, + modelLabel, + } = useProfileFieldBuckets({ + isBot, + isOwner, + managedAgent, + ownerDisplayName, + ownerHandle, + persona: resolvedPersona, + presenceLoaded: presenceQuery.isSuccess, + presenceStatus, + profile, + pubkey: effectivePubkey, + relayAgent, + }); const headerLeftContent = ( @@ -276,7 +693,7 @@ export function UserProfilePanel({ aria-label="Back to profile" className="shrink-0" data-testid="user-profile-panel-back" - onClick={() => onViewChange("summary")} + onClick={() => setView("summary")} size="icon" type="button" variant="outline" @@ -284,14 +701,16 @@ export function UserProfilePanel({ ) : null} - {panelTitle} + + {PROFILE_PANEL_VIEW_TITLES[view]} + ); const headerActions = (
- {view === "memories" && isOwner === true ? ( - + {view === "memories" && isOwner === true && effectivePubkey ? ( + ) : null}
); @@ -370,7 +870,44 @@ export function UserProfilePanel({ open={editAgentOpen} /> ) : null; - + const addAgentToChannelDialog = managedAgent ? ( + + ) : null; + const personaDialogs = ( + setPersonaToDelete(null)} + onCloseDialog={() => setPersonaDialogState(null)} + onConfirmDelete={(selectedPersona) => { + void handleConfirmDeletePersona(selectedPersona); + }} + onSubmit={handleSubmitPersona} + /> + ); if (isSplitLayout) { return ( <> @@ -382,6 +919,8 @@ export function UserProfilePanel({ {profileBody} {editAgentDialog} + {addAgentToChannelDialog} + {personaDialogs} ); } @@ -447,6 +986,8 @@ export function UserProfilePanel({ {profileBody} {editAgentDialog} + {addAgentToChannelDialog} + {personaDialogs} ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts new file mode 100644 index 000000000..2e4b30135 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts @@ -0,0 +1,159 @@ +import * as React from "react"; + +import { + deleteManagedAgentWithRules, + type ManagedAgentActionResult, +} from "@/features/agents/lib/managedAgentControlActions"; +import { removeChannelMember } from "@/shared/api/tauri"; +import type { + AgentPersona, + Channel, + ManagedAgent, + PresenceLookup, + RelayAgent, +} from "@/shared/api/types"; +import { getRelayAgentChannelIds } from "@/features/profile/ui/UserProfilePanelUtils"; + +type DeleteManagedAgentRulesContext = Omit< + Parameters[0], + "agent" +>; + +type DeleteProfileManagedAgentContext = DeleteManagedAgentRulesContext & { + removeAgentFromAllChannels: (pubkey: string) => Promise; +}; + +type DeleteProfileManagedAgentsForPersonaContext = + DeleteProfileManagedAgentContext & { + managedAgents: readonly ManagedAgent[]; + selectedAgent?: ManagedAgent; + }; + +type UseProfileAgentDeletionInput = { + channels?: readonly Channel[]; + deleteManagedAgent: DeleteManagedAgentRulesContext["deleteManagedAgent"]; + managedAgent?: ManagedAgent; + managedAgents?: readonly ManagedAgent[]; + presenceLookup?: PresenceLookup | null; + relayAgents?: readonly RelayAgent[]; +}; + +export function useProfileAgentDeletion({ + channels, + deleteManagedAgent, + managedAgent, + managedAgents, + presenceLookup, + relayAgents, +}: UseProfileAgentDeletionInput) { + const removeAgentFromAllChannels = React.useCallback( + async (agentPubkey: string) => { + const normalizedPubkey = agentPubkey.toLowerCase(); + const channelIds = new Set( + getRelayAgentChannelIds(relayAgents, agentPubkey), + ); + for (const channel of channels ?? []) { + if ( + channel.memberPubkeys.some( + (memberPubkey) => memberPubkey.toLowerCase() === normalizedPubkey, + ) + ) { + channelIds.add(channel.id); + } + } + if (channelIds.size === 0) return; + await Promise.allSettled( + [...channelIds].map((channelId) => + removeChannelMember(channelId, agentPubkey), + ), + ); + }, + [channels, relayAgents], + ); + + const deleteManagedAgentRecord = React.useCallback( + (agentToDelete: ManagedAgent) => + deleteProfileManagedAgent(agentToDelete, { + channels: channels ?? [], + deleteManagedAgent, + presenceLookup, + relayAgents: relayAgents ?? [], + removeAgentFromAllChannels, + }), + [ + channels, + deleteManagedAgent, + presenceLookup, + relayAgents, + removeAgentFromAllChannels, + ], + ); + + const deleteManagedAgentsForPersona = React.useCallback( + (persona: AgentPersona) => + deleteProfileManagedAgentsForPersona(persona, { + channels: channels ?? [], + deleteManagedAgent, + managedAgents: managedAgents ?? [], + presenceLookup, + relayAgents: relayAgents ?? [], + removeAgentFromAllChannels, + selectedAgent: managedAgent, + }), + [ + channels, + deleteManagedAgent, + managedAgent, + managedAgents, + presenceLookup, + relayAgents, + removeAgentFromAllChannels, + ], + ); + + return { + deleteManagedAgentRecord, + deleteManagedAgentsForPersona, + removeAgentFromAllChannels, + }; +} + +export async function deleteProfileManagedAgent( + agent: ManagedAgent, + context: DeleteProfileManagedAgentContext, +): Promise { + const { removeAgentFromAllChannels, ...deleteContext } = context; + const result = await deleteManagedAgentWithRules({ + agent, + ...deleteContext, + }); + if (result.cancelled) return result; + + await removeAgentFromAllChannels(agent.pubkey); + return result; +} + +export async function deleteProfileManagedAgentsForPersona( + persona: AgentPersona, + context: DeleteProfileManagedAgentsForPersonaContext, +): Promise { + const { managedAgents, selectedAgent, ...deleteContext } = context; + const agentsByPubkey = new Map(); + + for (const agent of managedAgents) { + if (agent.personaId === persona.id) { + agentsByPubkey.set(agent.pubkey, agent); + } + } + + if (selectedAgent?.personaId === persona.id) { + agentsByPubkey.set(selectedAgent.pubkey, selectedAgent); + } + + for (const agent of agentsByPubkey.values()) { + const result = await deleteProfileManagedAgent(agent, deleteContext); + if (result.cancelled) return result; + } + + return {}; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx new file mode 100644 index 000000000..7aa6d5aba --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -0,0 +1,450 @@ +import type { LucideIcon } from "lucide-react"; +import { + Activity, + Copy, + Cpu, + Fingerprint, + MessageSquare, + Server, + Terminal, + UserRound, +} from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; +import type { + AgentPersona, + ManagedAgent, + Profile, + RelayAgent, +} from "@/shared/api/types"; + +const RUNTIME_LABELS: Record = { + goose: "Goose", + "claude-code": "Claude Code", + "codex-acp": "Codex", + aider: "Aider", +}; + +function runtimeLabel(command: string): string { + return RUNTIME_LABELS[command] ?? command; +} + +async function copyToClipboard(value: string, label?: string) { + await navigator.clipboard.writeText(value); + toast.success(label ? `Copied ${label}` : "Copied to clipboard"); +} + +export type ProfileField = { + copyValue?: string; + displayValue: string; + displayNode?: React.ReactNode; + icon: LucideIcon; + label: string; + testId?: string; +}; + +const AGENT_INFO_LABELS = new Set([ + "Public key", + "Owned by", + "NIP-05", + "Agent type", + "Capabilities", + "Backend", +]); +const AGENT_SETTINGS_LABELS = new Set([ + "Runtime", + "Respond to", + "ACP command", + "MCP command", +]); +const DIAGNOSTICS_LABELS = new Set(["Status", "Last error"]); + +export function bucketProfileFields(fields: ProfileField[]) { + return { + agentInfoFields: fields.filter((field) => + AGENT_INFO_LABELS.has(field.label), + ), + agentSettingsFields: fields.filter((field) => + AGENT_SETTINGS_LABELS.has(field.label), + ), + diagnosticsFields: fields.filter((field) => + DIAGNOSTICS_LABELS.has(field.label), + ), + }; +} + +export function useProfileFieldBuckets({ + isBot, + isOwner, + managedAgent, + ownerDisplayName, + ownerHandle, + persona, + presenceLoaded, + presenceStatus, + profile, + pubkey, + relayAgent, +}: { + isBot: boolean; + isOwner: boolean | undefined; + managedAgent: ManagedAgent | undefined; + ownerDisplayName: string | null; + ownerHandle: string | null; + persona: AgentPersona | undefined; + presenceLoaded: boolean; + presenceStatus: "online" | "away" | "offline" | undefined; + profile: Profile | undefined; + pubkey: string | null; + relayAgent: RelayAgent | undefined; +}) { + return React.useMemo(() => { + const metadataFields = [ + ...buildPublicFields({ pubkey, profile, relayAgent, isBot, persona }), + ...(isOwner === true + ? buildOwnerFields({ + managedAgent, + ownerDisplayName, + ownerHandle, + persona, + presenceLoaded, + presenceStatus, + relayAgent, + }) + : []), + ]; + const diagnosticsFields = + bucketProfileFields(metadataFields).diagnosticsFields; + + return { + ...bucketProfileFields(metadataFields), + diagnosticsSummary: + diagnosticsFields.find((field) => field.label === "Status") + ?.displayValue ?? + diagnosticsFields.find((field) => field.label === "Last error") + ?.displayValue ?? + null, + modelLabel: managedAgent?.model ?? persona?.model ?? "Auto", + }; + }, [ + isBot, + isOwner, + managedAgent, + ownerDisplayName, + ownerHandle, + persona, + presenceLoaded, + presenceStatus, + profile, + pubkey, + relayAgent, + ]); +} + +export function buildPublicFields({ + isBot, + persona, + profile, + pubkey, + relayAgent, +}: { + isBot: boolean; + persona?: AgentPersona; + profile: Profile | undefined; + pubkey: string | null; + relayAgent: RelayAgent | undefined; +}): ProfileField[] { + const fields: ProfileField[] = []; + + if (pubkey) { + fields.push({ + copyValue: pubkey, + displayValue: truncatePubkeyShort(pubkey), + icon: Fingerprint, + label: "Public key", + testId: "user-profile-copy-pubkey", + }); + } + + if (profile?.nip05Handle) { + fields.push({ + copyValue: profile.nip05Handle, + displayValue: profile.nip05Handle, + icon: UserRound, + label: "NIP-05", + testId: "user-profile-nip05", + }); + } + + if (isBot && relayAgent?.agentType) { + fields.push({ + copyValue: relayAgent.agentType, + displayValue: runtimeLabel(relayAgent.agentType), + icon: Cpu, + label: "Agent type", + testId: "user-profile-agent-type", + }); + } + + if (!pubkey && persona) { + fields.push({ + displayValue: "Not deployed", + icon: Activity, + label: "Status", + testId: "user-profile-agent-status", + }); + } + + if (relayAgent?.capabilities.length) { + fields.push({ + copyValue: relayAgent.capabilities.join(", "), + displayValue: relayAgent.capabilities.join(", "), + icon: Server, + label: "Capabilities", + testId: "user-profile-capabilities", + }); + } + + return fields; +} + +export function buildOwnerFields({ + managedAgent, + ownerDisplayName, + ownerHandle, + persona, + presenceLoaded, + presenceStatus, + relayAgent, +}: { + managedAgent: ManagedAgent | undefined; + ownerDisplayName: string | null; + ownerHandle: string | null; + persona?: AgentPersona; + presenceLoaded: boolean; + presenceStatus: "online" | "away" | "offline" | undefined; + relayAgent: RelayAgent | undefined; +}): ProfileField[] { + const fields: ProfileField[] = []; + + if (ownerDisplayName) { + fields.push({ + copyValue: ownerHandle ?? undefined, + displayValue: ownerDisplayName, + icon: UserRound, + label: "Owned by", + testId: "user-profile-owned-by", + }); + } + + if (managedAgent?.agentCommand) { + fields.push({ + copyValue: managedAgent.agentCommand, + displayValue: runtimeLabel(managedAgent.agentCommand), + icon: Terminal, + label: "Runtime", + testId: "user-profile-runtime", + }); + } else if (relayAgent?.agentType) { + fields.push({ + copyValue: relayAgent.agentType, + displayValue: runtimeLabel(relayAgent.agentType), + icon: Terminal, + label: "Runtime", + testId: "user-profile-runtime", + }); + } else if (persona?.runtime) { + fields.push({ + copyValue: persona.runtime, + displayValue: runtimeLabel(persona.runtime), + icon: Terminal, + label: "Runtime", + testId: "user-profile-runtime", + }); + } + + if (managedAgent) { + fields.push({ + displayValue: managedAgent.status + .replace(/_/g, " ") + .replace(/\b\w/g, (char: string) => char.toUpperCase()), + displayNode: ( + + ), + icon: Activity, + label: "Status", + testId: "user-profile-agent-status", + }); + } + + if (managedAgent?.model) { + fields.push({ + copyValue: managedAgent.model, + displayValue: managedAgent.model, + icon: Cpu, + label: "Model", + testId: "user-profile-model", + }); + } else if (persona?.model) { + fields.push({ + copyValue: persona.model, + displayValue: persona.model, + icon: Cpu, + label: "Model", + testId: "user-profile-model", + }); + } + + if (managedAgent?.acpCommand) { + fields.push({ + copyValue: managedAgent.acpCommand, + displayValue: managedAgent.acpCommand, + icon: Terminal, + label: "ACP command", + testId: "user-profile-acp", + }); + } + + if (managedAgent?.mcpCommand) { + fields.push({ + copyValue: managedAgent.mcpCommand, + displayValue: managedAgent.mcpCommand, + icon: Terminal, + label: "MCP command", + testId: "user-profile-mcp", + }); + } + + if (managedAgent?.backend.type === "provider") { + const backendLabel = managedAgent.backend.id; + fields.push({ + copyValue: backendLabel, + displayValue: backendLabel, + icon: Server, + label: "Backend", + testId: "user-profile-backend", + }); + } + + if (managedAgent) { + fields.push({ + displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No", + icon: Server, + label: "Start on launch", + testId: "user-profile-start-on-launch", + }); + fields.push({ + displayValue: managedAgent.respondTo.replace(/-/g, " "), + icon: MessageSquare, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } + + if (managedAgent?.lastError) { + fields.push({ + copyValue: managedAgent.lastError, + displayValue: managedAgent.lastError, + icon: Activity, + label: "Last error", + testId: "user-profile-last-error", + }); + } + + return fields; +} + +export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { + const publicKeyLabel = "Public key"; + const ownedByLabel = "Owned by"; + const statusLabel = "Status"; + const orderedFields = [ + ...fields.filter((field) => field.label === publicKeyLabel), + ...fields.filter((field) => field.label === ownedByLabel), + ...fields.filter( + (field) => + field.label !== publicKeyLabel && + field.label !== ownedByLabel && + field.copyValue, + ), + ...fields.filter((field) => field.label === statusLabel), + ...fields.filter((field) => { + if ( + field.label === publicKeyLabel || + field.label === ownedByLabel || + field.label === statusLabel + ) { + return false; + } + return !field.copyValue; + }), + ]; + + return ( +
+
+ {orderedFields.map((field) => ( + + ))} +
+
+ ); +} + +function ProfileFieldRow({ field }: { field: ProfileField }) { + const Icon = field.icon; + const isCopyable = Boolean(field.copyValue); + + const content = ( + <> + + + + + + {field.label} + + + {field.displayNode ?? field.displayValue} + + + {isCopyable ? ( + + ) : null} + + ); + + if (isCopyable && field.copyValue) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs new file mode 100644 index 000000000..406cc3f09 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { validateLinkedAgentRuntimeEdit } from "./UserProfilePanelPersonaSubmit.ts"; + +function agent(overrides = {}) { + return { + pubkey: "deadbeef".repeat(8), + name: "Fizz", + personaId: "persona-1", + relayUrl: "ws://localhost:3000", + acpCommand: "buzz-acp", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 320, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: "Prompt", + avatarUrl: null, + model: null, + mcpToolsets: null, + envVars: {}, + status: "stopped", + pid: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + logPath: null, + startOnAppLaunch: true, + backend: { type: "local" }, + backendAgentId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function persona(overrides = {}) { + return { + id: "persona-1", + displayName: "Fizz", + avatarUrl: null, + systemPrompt: "Prompt", + runtime: "goose", + model: null, + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + envVars: {}, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function updateInput(overrides = {}) { + return { + id: "persona-1", + displayName: "Fizz", + avatarUrl: undefined, + systemPrompt: "Prompt", + runtime: "claude", + model: undefined, + provider: undefined, + namePool: [], + ...overrides, + }; +} + +function runtime(overrides = {}) { + return { + id: "claude", + label: "Claude Code", + avatarUrl: "", + availability: "available", + command: "claude", + binaryPath: "/usr/local/bin/claude", + defaultArgs: [], + mcpCommand: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + ...overrides, + }; +} + +test("validateLinkedAgentRuntimeEdit allows available runtime changes", () => { + assert.equal( + validateLinkedAgentRuntimeEdit({ + input: updateInput({ runtime: "claude" }), + managedAgent: agent(), + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime()], + }), + null, + ); +}); + +test("validateLinkedAgentRuntimeEdit rejects unavailable linked-agent runtime changes", () => { + assert.equal( + validateLinkedAgentRuntimeEdit({ + input: updateInput({ runtime: "claude" }), + managedAgent: agent(), + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime({ availability: "cli_missing", command: null })], + }), + "Claude Code is not available. Install it before saving this linked agent.", + ); +}); + +test("validateLinkedAgentRuntimeEdit allows unchanged or unlinked runtime preferences", () => { + assert.equal( + validateLinkedAgentRuntimeEdit({ + input: updateInput({ runtime: "goose" }), + managedAgent: agent(), + previousPersona: persona({ runtime: "goose" }), + runtimes: [], + }), + null, + ); + + assert.equal( + validateLinkedAgentRuntimeEdit({ + input: updateInput({ runtime: "claude" }), + managedAgent: undefined, + previousPersona: persona({ runtime: "goose" }), + runtimes: [], + }), + null, + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts new file mode 100644 index 000000000..eff9a1af1 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts @@ -0,0 +1,135 @@ +import { toast } from "sonner"; + +import { personaManagedAgentUpdate } from "@/features/profile/ui/UserProfilePanelUtils"; +import type { + AcpRuntimeCatalogEntry, + AgentPersona, + CreateManagedAgentResponse, + CreatePersonaInput, + ManagedAgent, + UpdateManagedAgentInput, + UpdatePersonaInput, +} from "@/shared/api/types"; + +type SubmitProfilePersonaDialogOptions = { + createManagedAgentForPersona: ( + persona: AgentPersona, + ) => Promise; + createPersona: (input: CreatePersonaInput) => Promise; + input: CreatePersonaInput | UpdatePersonaInput; + managedAgent: ManagedAgent | undefined; + onDone: () => void; + previousPersona?: AgentPersona; + runtimes?: readonly AcpRuntimeCatalogEntry[]; + updateManagedAgent: ( + input: UpdateManagedAgentInput, + ) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>; + updatePersona: (input: UpdatePersonaInput) => Promise; +}; + +type ValidateLinkedAgentRuntimeEditOptions = { + input: UpdatePersonaInput; + managedAgent: ManagedAgent | undefined; + previousPersona?: AgentPersona; + runtimes?: readonly AcpRuntimeCatalogEntry[]; +}; + +function normalizeRuntimePreference(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +export function validateLinkedAgentRuntimeEdit({ + input, + managedAgent, + previousPersona, + runtimes, +}: ValidateLinkedAgentRuntimeEditOptions): string | null { + if (!managedAgent || !previousPersona) { + return null; + } + + const previousRuntime = normalizeRuntimePreference(previousPersona.runtime); + const nextRuntime = normalizeRuntimePreference(input.runtime); + if (previousRuntime === nextRuntime) { + return null; + } + + const runtime = runtimes?.find((candidate) => candidate.id === nextRuntime); + if (runtime?.availability === "available" && runtime.command) { + return null; + } + + const runtimeLabel = runtime?.label ?? "This provider"; + return `${runtimeLabel} is not available. Install it before saving this linked agent.`; +} + +export async function submitProfilePersonaDialog({ + createManagedAgentForPersona, + createPersona, + input, + managedAgent, + onDone, + previousPersona, + runtimes, + updateManagedAgent, + updatePersona, +}: SubmitProfilePersonaDialogOptions) { + try { + if ("id" in input) { + const runtimeEditError = validateLinkedAgentRuntimeEdit({ + input, + managedAgent, + previousPersona, + runtimes, + }); + if (runtimeEditError) { + toast.error(runtimeEditError); + return; + } + + const persona = await updatePersona(input); + const agentUpdate = managedAgent + ? personaManagedAgentUpdate(managedAgent, persona, { + previousPersona, + runtimes, + }) + : null; + const result = agentUpdate ? await updateManagedAgent(agentUpdate) : null; + if (result?.profileSyncError) { + toast.warning( + `${result.agent.name} was updated, but profile sync failed: ${result.profileSyncError}`, + ); + } + toast.success(`Updated ${input.displayName}.`); + } else { + const persona = await createPersona(input); + try { + const created = await createManagedAgentForPersona(persona); + if (created.spawnError) { + toast.error( + `${persona.displayName} was created, but it did not start: ${created.spawnError}`, + ); + } else { + toast.success(`Created and started ${created.agent.name}.`); + } + if (created.profileSyncError) { + toast.warning( + `${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`, + ); + } + } catch (error) { + toast.error( + error instanceof Error + ? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}` + : `${persona.displayName} was created, but the agent instance could not be created.`, + ); + } + } + + onDone(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to save agent.", + ); + } +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 3baa46ea2..a52a912b4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -7,24 +7,26 @@ import { ChevronDown, ChevronRight, ChevronUp, - Copy, Cpu, - Fingerprint, + FileText, Hash, + Info, MessageSquare, Pencil, - Server, - Terminal, + Play, + Power, + Settings, + Square, UserMinus, UserPlus, - UserRound, } from "lucide-react"; import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; -import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { ModelPicker } from "@/features/agents/ui/ModelPicker"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -33,61 +35,73 @@ import type { useUnfollowMutation, useUserProfileQuery, } from "@/features/profile/hooks"; -import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; +import { + type ProfileField, + ProfileFieldGroup, +} from "@/features/profile/ui/UserProfilePanelFields"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; -import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { UserProfileAgentActions } from "@/features/profile/ui/UserProfileAgentActions"; +import type { + AgentPersona, + ManagedAgent, + RelayAgent, +} from "@/shared/api/types"; import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; - -const RUNTIME_LABELS: Record = { - goose: "Goose", - "claude-code": "Claude Code", - "codex-acp": "Codex", - aider: "Aider", -}; - -function runtimeLabel(command: string): string { - return RUNTIME_LABELS[command] ?? command; -} - -async function copyToClipboard(value: string, label?: string) { - await navigator.clipboard.writeText(value); - toast.success(label ? `Copied ${label}` : "Copied to clipboard"); -} +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { canEditAgent: boolean; + canOpenAgentLogs: boolean; canViewActivity: boolean; channelCount: number; channelIdToName: Record; channelsLoading: boolean; displayName: string; followMutation: ReturnType; + canInstantiateAgent: boolean; + agentInstruction: string | null; + handleAgentPrimaryAction: () => void; + handleDeletePersona?: () => void; + handleDuplicatePersona?: () => void; + handleDeleteAgent: () => void; handleEditAgent: () => void; + handleEditPersona?: () => void; + handleExportPersona?: () => void; + handleInstantiateAgent: () => void; handleMessage: () => void; - handleOpenActivity: () => void; isBot: boolean; + isAgentActionPending: boolean; isFollowing: boolean; isOwner: boolean | undefined; isSelf: boolean; managedAgent: ManagedAgent | undefined; memoriesLoading: boolean; memoryCount: number | undefined; - ownerDisplayName: string | null; - ownerHandle: string | null; + agentInfoFields: ProfileField[]; + agentSettingsFields: ProfileField[]; + diagnosticsFields: ProfileField[]; + diagnosticsSummary: string | null; + modelLabel: string; + onOpenAgentInfo: () => void; + onOpenAgentSettings: () => void; onOpenChannels: () => void; + onOpenDiagnostics: () => void; + onOpenInstruction: () => void; onOpenMemories: () => void; + onOpenModel: () => void; onOpenDm?: (pubkeys: string[]) => void; - presenceLoaded: boolean; + persona?: AgentPersona; presenceStatus: "online" | "away" | "offline" | undefined; profile: ReturnType["data"]; - pubkey: string; + pubkey: string | null; relayAgent: RelayAgent | undefined; unfollowMutation: ReturnType; userStatus: { text: string; emoji: string } | null | undefined; @@ -95,28 +109,46 @@ export type ProfileSummaryViewProps = { export function ProfileSummaryView({ canEditAgent, + canOpenAgentLogs, canViewActivity, channelCount, channelIdToName, channelsLoading, displayName, followMutation, + canInstantiateAgent, + agentInstruction, + handleAgentPrimaryAction, + handleDeletePersona, + handleDuplicatePersona, + handleDeleteAgent, handleEditAgent, + handleEditPersona, + handleExportPersona, + handleInstantiateAgent, handleMessage, - handleOpenActivity, isBot, + isAgentActionPending, isFollowing, isOwner, isSelf, managedAgent, memoriesLoading, memoryCount, - ownerDisplayName, - ownerHandle, + agentInfoFields, + agentSettingsFields, + diagnosticsFields, + diagnosticsSummary, + modelLabel, + onOpenAgentInfo, + onOpenAgentSettings, onOpenChannels, + onOpenDiagnostics, + onOpenInstruction, onOpenMemories, + onOpenModel, onOpenDm, - presenceLoaded, + persona, presenceStatus, profile, pubkey, @@ -127,28 +159,20 @@ export function ProfileSummaryView({ const { goChannel } = useAppNavigation(); const activeTurns = useActiveAgentTurns(isBot ? pubkey : null); - const metadataFields = [ - ...buildPublicFields({ - pubkey, - profile, - relayAgent, - isBot, - }), - ...(isOwner === true - ? buildOwnerFields({ - managedAgent, - ownerDisplayName, - ownerHandle, - presenceLoaded, - presenceStatus, - relayAgent, - }) - : []), - ]; - - const showMemoriesIngress = isOwner === true; + const showMemoriesIngress = isOwner === true && Boolean(pubkey); + const showInstructionIngress = + isOwner === true && + (agentInstruction !== null || handleEditPersona !== undefined); const showChannelsIngress = channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined; + const showModelIngress = isOwner === true && isBot; + const showAgentSettingsIngress = + isOwner === true && + (agentSettingsFields.length > 0 || managedAgent?.backend.type === "local"); + const showDiagnosticsIngress = + diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; + const showAgentInfoIngress = agentInfoFields.length > 0; + const personaActionKey = persona?.id; return (
@@ -160,11 +184,33 @@ export function ProfileSummaryView({ userStatus={userStatus} /> - {!isSelf ? ( + {canInstantiateAgent ? ( + + ) : !isSelf && pubkey ? ( ) : null} - {showMemoriesIngress || showChannelsIngress || canViewActivity ? ( + {showInstructionIngress || + showModelIngress || + showMemoriesIngress || + showChannelsIngress || + showAgentSettingsIngress || + showDiagnosticsIngress || + showAgentInfoIngress ? (
+ {showInstructionIngress ? ( + + ) : null} + {showModelIngress ? ( + + ) : null} {showMemoriesIngress ? ( ) : null} - {canViewActivity ? ( + {showAgentSettingsIngress ? ( + + ) : null} + {showDiagnosticsIngress ? ( + ) : null} + {showAgentInfoIngress ? ( + ) : null}
) : null} - {metadataFields.length > 0 ? ( - + {isOwner === true && managedAgent ? ( + + ) : null} + {canInstantiateAgent ? ( + ) : null}
); @@ -415,17 +520,25 @@ function ProfileHeroDescription({ about }: { about: string }) { // ── Primary actions ────────────────────────────────────────────────────────── function ProfilePrimaryActions({ + agentActionDisabled, + agentActionLabel, + agentActionLive, canEditAgent, followMutation, isFollowing, + onAgentPrimaryAction, onEditAgent, onMessage, pubkey, unfollowMutation, }: { + agentActionDisabled?: boolean; + agentActionLabel?: string; + agentActionLive?: boolean; canEditAgent: boolean; followMutation: ReturnType; isFollowing: boolean; + onAgentPrimaryAction?: () => void; onEditAgent: () => void; onMessage?: () => void; pubkey: string; @@ -470,6 +583,49 @@ function ProfilePrimaryActions({ testId="user-profile-edit-agent" /> ) : null} + {onAgentPrimaryAction && agentActionLabel ? ( + + ) : null} + + ); +} + +function ProfilePersonaPrimaryActions({ + canEditAgent, + disabled, + onEditAgent, + onStartAgent, +}: { + canEditAgent: boolean; + disabled: boolean; + onEditAgent: () => void; + onStartAgent: () => void; +}) { + return ( +
+ + {canEditAgent ? ( + + ) : null}
); } @@ -519,309 +675,17 @@ function ProfileQuickAction({ ); } -// ── Field rows ─────────────────────────────────────────────────────────────── - -type ProfileField = { - copyValue?: string; - /** - * Plain-text representation. Always required so non-visual surfaces (e.g. tooltips, - * copy-to-clipboard) keep working. When `displayNode` is set, the row renders that - * instead of the text — but the text still drives the title/tooltip. - */ - displayValue: string; - /** - * Optional rich rendering for the value cell (e.g. a status badge). When present, - * replaces the plain text node in the row. - */ - displayNode?: React.ReactNode; - icon: LucideIcon; - label: string; - testId?: string; -}; - -function buildPublicFields({ - isBot, - profile, - pubkey, - relayAgent, -}: { - isBot: boolean; - profile: ProfileSummaryViewProps["profile"]; - pubkey: string; - relayAgent: RelayAgent | undefined; -}): ProfileField[] { - const fields: ProfileField[] = [ - { - copyValue: pubkey, - displayValue: truncatePubkeyShort(pubkey), - icon: Fingerprint, - label: "Public key", - testId: "user-profile-copy-pubkey", - }, - ]; - - if (profile?.nip05Handle) { - fields.push({ - copyValue: profile.nip05Handle, - displayValue: profile.nip05Handle, - icon: UserRound, - label: "NIP-05", - testId: "user-profile-nip05", - }); - } - - if (isBot && relayAgent?.agentType) { - fields.push({ - copyValue: relayAgent.agentType, - displayValue: runtimeLabel(relayAgent.agentType), - icon: Cpu, - label: "Agent type", - testId: "user-profile-agent-type", - }); - } - - if (relayAgent?.capabilities.length) { - fields.push({ - copyValue: relayAgent.capabilities.join(", "), - displayValue: relayAgent.capabilities.join(", "), - icon: Server, - label: "Capabilities", - testId: "user-profile-capabilities", - }); - } - - return fields; -} - -function buildOwnerFields({ - managedAgent, - ownerDisplayName, - ownerHandle, - presenceLoaded, - presenceStatus, - relayAgent, -}: { - managedAgent: ManagedAgent | undefined; - ownerDisplayName: string | null; - ownerHandle: string | null; - presenceLoaded: boolean; - presenceStatus: "online" | "away" | "offline" | undefined; - relayAgent: RelayAgent | undefined; -}): ProfileField[] { - const fields: ProfileField[] = []; - - if (ownerDisplayName) { - fields.push({ - copyValue: ownerHandle ?? undefined, - displayValue: ownerDisplayName, - icon: UserRound, - label: "Owned by", - testId: "user-profile-owned-by", - }); - } - - if (managedAgent?.agentCommand) { - fields.push({ - copyValue: managedAgent.agentCommand, - displayValue: runtimeLabel(managedAgent.agentCommand), - icon: Terminal, - label: "Runtime", - testId: "user-profile-runtime", - }); - } else if (relayAgent?.agentType) { - fields.push({ - copyValue: relayAgent.agentType, - displayValue: runtimeLabel(relayAgent.agentType), - icon: Terminal, - label: "Runtime", - testId: "user-profile-runtime", - }); - } - - if (managedAgent) { - fields.push({ - displayValue: managedAgent.status - .replace(/_/g, " ") - .replace(/\b\w/g, (char: string) => char.toUpperCase()), - displayNode: ( - - ), - icon: Activity, - label: "Status", - testId: "user-profile-agent-status", - }); - } - - if (managedAgent?.model) { - fields.push({ - copyValue: managedAgent.model, - displayValue: managedAgent.model, - icon: Cpu, - label: "Model", - testId: "user-profile-model", - }); - } - - if (managedAgent?.acpCommand) { - fields.push({ - copyValue: managedAgent.acpCommand, - displayValue: managedAgent.acpCommand, - icon: Terminal, - label: "ACP command", - testId: "user-profile-acp", - }); - } - - if (managedAgent?.mcpCommand) { - fields.push({ - copyValue: managedAgent.mcpCommand, - displayValue: managedAgent.mcpCommand, - icon: Terminal, - label: "MCP command", - testId: "user-profile-mcp", - }); - } - - if (managedAgent?.backend.type === "provider") { - const backendLabel = managedAgent.backend.id; - fields.push({ - copyValue: backendLabel, - displayValue: backendLabel, - icon: Server, - label: "Backend", - testId: "user-profile-backend", - }); - } - - if (managedAgent) { - fields.push({ - displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No", - icon: Server, - label: "Start on launch", - testId: "user-profile-start-on-launch", - }); - fields.push({ - displayValue: managedAgent.respondTo.replace(/-/g, " "), - icon: MessageSquare, - label: "Respond to", - testId: "user-profile-respond-to", - }); - } - - if (managedAgent?.lastError) { - fields.push({ - copyValue: managedAgent.lastError, - displayValue: managedAgent.lastError, - icon: Activity, - label: "Last error", - testId: "user-profile-last-error", - }); - } - - return fields; -} - -function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { - const publicKeyLabel = "Public key"; - const ownedByLabel = "Owned by"; - const statusLabel = "Status"; - const orderedFields = [ - ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === ownedByLabel), - ...fields.filter( - (field) => - field.label !== publicKeyLabel && - field.label !== ownedByLabel && - field.copyValue, - ), - ...fields.filter((field) => field.label === statusLabel), - ...fields.filter((field) => { - if ( - field.label === publicKeyLabel || - field.label === ownedByLabel || - field.label === statusLabel - ) { - return false; - } - return !field.copyValue; - }), - ]; - - return ( -
-
- {orderedFields.map((field) => ( - - ))} -
-
- ); -} - -function ProfileFieldRow({ field }: { field: ProfileField }) { - const Icon = field.icon; - const isCopyable = Boolean(field.copyValue); - - const content = ( - <> - - - - - - {field.label} - - - {field.displayNode ?? field.displayValue} - - - {isCopyable ? ( - - ) : null} - - ); - - if (isCopyable && field.copyValue) { - return ( - - ); - } - - return ( -
- {content} -
- ); -} - // ── Ingress rows ───────────────────────────────────────────────────────────── function ProfileIngressRow({ + disabled, icon: Icon, label, onClick, testId, trailing, }: { + disabled?: boolean; icon: LucideIcon; label: string; onClick: () => void; @@ -830,8 +694,9 @@ function ProfileIngressRow({ }) { return ( @@ -875,55 +745,246 @@ type ProfileChannelLink = { }; export function ChannelsFocusedView({ + canAddToChannel, channels, + isActionPending, isLoading, + onAddToChannel, onOpenChannel, }: { + canAddToChannel: boolean; channels: ProfileChannelLink[]; + isActionPending: boolean; isLoading: boolean; + onAddToChannel: () => void; onOpenChannel: (channelId: string) => void; }) { - if (isLoading) { - return ( -

- Loading channels… -

- ); + return ( +
+ {canAddToChannel ? ( + + ) : null} + {isLoading ? ( +

+ Loading channels… +

+ ) : channels.length === 0 ? ( +

+ No visible channel memberships. +

+ ) : ( +
    + {channels.map((channel) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +export function AgentInfoFocusedView({ + metadataFields, +}: { + metadataFields: ProfileField[]; +}) { + if (metadataFields.length === 0) { + return null; } - if (channels.length === 0) { - return ( -

- No visible channel memberships. -

- ); + return ( +
+ +
+ ); +} + +export function ModelFocusedView({ + managedAgent, + modelLabel, + onModelChanged, +}: { + managedAgent: ManagedAgent | undefined; + modelLabel: string; + onModelChanged: () => void; +}) { + return ( +
+
+ + + + + + Model + + + {modelLabel} + + + {managedAgent ? ( + + ) : null} +
+
+ ); +} + +export function AgentSettingsFocusedView({ + fields, + isActionPending, + managedAgent, + onToggleAutoStart, +}: { + fields: ProfileField[]; + isActionPending: boolean; + managedAgent: ManagedAgent | undefined; + onToggleAutoStart: () => void; +}) { + const canToggleAutoStart = + managedAgent !== undefined && managedAgent.backend.type === "local"; + + if (fields.length === 0 && !canToggleAutoStart) { + return null; } return ( -
    - {channels.map((channel) => ( -
  • - -
  • - ))} -
+ + ) : ( +

+ No instruction set. +

+ )} + + {onEdit ? ( + + ) : null} + ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs new file mode 100644 index 000000000..d7839790d --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseProfilePanelView, + personaManagedAgentUpdate, + profilePanelViewFromSearch, +} from "./UserProfilePanelUtils.ts"; + +function agent(overrides = {}) { + return { + pubkey: "deadbeef".repeat(8), + name: "Fizz", + personaId: "persona-1", + relayUrl: "ws://localhost:3000", + acpCommand: "buzz-acp", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 320, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: "Old prompt", + avatarUrl: "app-avatar://old", + model: "old-model", + mcpToolsets: null, + envVars: { OLD_KEY: "1" }, + status: "stopped", + pid: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + logPath: null, + startOnAppLaunch: true, + backend: { type: "local" }, + backendAgentId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function persona(overrides = {}) { + return { + id: "persona-1", + displayName: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + runtime: "goose", + model: "new-model", + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + envVars: { NEW_KEY: "2" }, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function runtime(overrides = {}) { + return { + id: "claude", + label: "Claude Code", + avatarUrl: "app-avatar://claude", + availability: "available", + command: "claude", + binaryPath: "/usr/local/bin/claude", + defaultArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + ...overrides, + }; +} + +test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => { + assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + }); +}); + +test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { + assert.equal( + personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), + null, + ); + assert.equal( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + }), + persona(), + ), + null, + ); +}); + +test("personaManagedAgentUpdate maps changed persona runtime to linked agent commands", () => { + assert.deepEqual( + personaManagedAgentUpdate(agent(), persona({ runtime: "claude" }), { + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime()], + }), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + agentCommand: "claude", + agentArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + }, + ); +}); + +test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => { + assert.equal( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + agentArgs: ["custom"], + }), + persona({ runtime: "goose" }), + { + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime({ id: "goose", command: "goose" })], + }, + ), + null, + ); +}); + +test("parseProfilePanelView accepts all profile panel subviews", () => { + for (const view of [ + "summary", + "info", + "settings", + "diagnostics", + "model", + "instructions", + "memories", + "channels", + "logs", + ]) { + assert.equal(parseProfilePanelView(view), view); + } +}); + +test("profilePanelViewFromSearch falls back to summary for invalid values", () => { + assert.equal(parseProfilePanelView("missing"), null); + assert.equal(profilePanelViewFromSearch("missing"), "summary"); + assert.equal(profilePanelViewFromSearch(null), "summary"); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts new file mode 100644 index 000000000..c7abfb129 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -0,0 +1,324 @@ +import * as React from "react"; +import type { + AcpRuntimeCatalogEntry, + AgentPersona, + Channel, + ManagedAgent, + Profile, + RelayAgent, + UpdateManagedAgentInput, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type ProfileChannelLink = { + id: string; + name: string; +}; + +export type ProfilePanelView = + | "summary" + | "info" + | "settings" + | "diagnostics" + | "model" + | "instructions" + | "memories" + | "channels" + | "logs"; + +export const PROFILE_PANEL_VIEW_TITLES: Record = { + summary: "Profile", + info: "Agent info", + settings: "Agent settings", + diagnostics: "Diagnostics", + model: "Model", + instructions: "Agent instruction", + memories: "Memories", + channels: "Channels", + logs: "Harness log", +}; + +const PROFILE_PANEL_VIEWS = new Set( + Object.keys(PROFILE_PANEL_VIEW_TITLES) as ProfilePanelView[], +); + +export function parseProfilePanelView(value: unknown): ProfilePanelView | null { + return typeof value === "string" && + PROFILE_PANEL_VIEWS.has(value as ProfilePanelView) + ? (value as ProfilePanelView) + : null; +} + +export function profilePanelViewFromSearch(value: unknown): ProfilePanelView { + return parseProfilePanelView(value) ?? "summary"; +} + +export type UserProfilePanelProps = { + canResetWidth?: boolean; + currentPubkey?: string; + isSinglePanelView?: boolean; + layout?: "standalone" | "split"; + onClose: () => void; + onOpenDm?: (pubkeys: string[]) => void; + onResetWidth?: () => void; + onResizeStart?: (event: React.PointerEvent) => void; + onViewChange?: ( + view: ProfilePanelView, + options?: { replace?: boolean }, + ) => void; + persona?: AgentPersona; + pubkey?: string; + splitPaneClamp?: boolean; + view?: ProfilePanelView; + widthPx: number; +}; + +export function truncatePubkey(pubkey: string) { + if (pubkey.length <= 16) { + return pubkey; + } + + return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; +} + +export function deriveProfileChannels( + pubkeyLower: string, + relayAgent: RelayAgent | undefined, + managedAgent: ManagedAgent | undefined, + channels: Channel[] | undefined, +): ProfileChannelLink[] { + const links = new Map(); + const channelsByName = new Map( + channels?.map((channel) => [channel.name, channel]) ?? [], + ); + + relayAgent?.channels.forEach((name, index) => { + const channel = channelsByName.get(name); + const id = relayAgent.channelIds[index] ?? channel?.id ?? name; + links.set(id, { id, name }); + }); + + if (managedAgent && channels) { + for (const channel of channels) { + const isMember = channel.memberPubkeys.some( + (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, + ); + if (isMember) { + links.set(channel.id, { id: channel.id, name: channel.name }); + } + } + } + + return [...links.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); +} + +export function getRelayAgentChannelIds( + relayAgents: readonly RelayAgent[] | undefined, + agentPubkey: string, +): string[] { + const normalized = normalizePubkey(agentPubkey); + const agent = (relayAgents ?? []).find( + (candidate) => normalizePubkey(candidate.pubkey) === normalized, + ); + return agent?.channelIds ?? []; +} + +export function buildPersonaDraftProfile(persona: AgentPersona): Profile { + return { + pubkey: "", + displayName: persona.displayName, + avatarUrl: persona.avatarUrl, + about: null, + nip05Handle: null, + }; +} + +export function resolvePanelProfile({ + persona, + profile, +}: { + managedAgent: ManagedAgent | undefined; + persona: AgentPersona | undefined; + profile: Profile | undefined; +}): Profile | undefined { + const baseProfile = + profile ?? (persona ? buildPersonaDraftProfile(persona) : undefined); + return withProfileAvatarFallback(baseProfile, [persona?.avatarUrl]); +} + +export function resolveProfileAvatarUrl( + ...candidates: Array +): string | null { + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} + +export function withProfileAvatarFallback( + profile: Profile | undefined, + fallbackAvatarUrls: Array, +): Profile | undefined { + const profileAvatarUrl = normalizeProfileFallbackAvatarUrl( + profile?.avatarUrl, + ); + const avatarUrl = resolveProfileAvatarUrl( + profileAvatarUrl, + ...fallbackAvatarUrls.map((avatarUrl) => + normalizeProfileFallbackAvatarUrl(avatarUrl), + ), + ); + return profile && avatarUrl !== profile.avatarUrl + ? { ...profile, avatarUrl } + : profile; +} + +function normalizeProfileFallbackAvatarUrl( + avatarUrl: string | null | undefined, +): string | null { + const trimmed = avatarUrl?.trim(); + if (!trimmed) return null; + return trimmed; +} + +export function resolveProfileDisplayName({ + persona, + profile, + pubkey, +}: { + persona: AgentPersona | undefined; + profile: Profile | undefined; + pubkey: string | null; +}) { + return ( + profile?.displayName ?? + persona?.displayName ?? + (pubkey ? truncatePubkey(pubkey) : "Agent") + ); +} + +export function resolveOwnerHandle( + profile: Profile | undefined, + currentPubkey: string | undefined, +) { + if (currentPubkey === undefined) { + return null; + } + + return ( + profile?.nip05Handle?.trim() || + profile?.displayName?.trim() || + truncatePubkey(currentPubkey) + ); +} + +export function resolveAgentInstruction( + managedAgent: ManagedAgent | undefined, + persona: AgentPersona | undefined, +) { + return ( + managedAgent?.systemPrompt?.trim() || persona?.systemPrompt.trim() || null + ); +} + +export function personaManagedAgentUpdate( + agent: ManagedAgent, + persona: AgentPersona, + options: { + previousPersona?: AgentPersona; + runtimes?: readonly AcpRuntimeCatalogEntry[]; + } = {}, +): UpdateManagedAgentInput | null { + if (agent.personaId !== persona.id) return null; + + const input: UpdateManagedAgentInput = { pubkey: agent.pubkey }; + let hasChanges = false; + + if (persona.displayName !== agent.name) { + input.name = persona.displayName; + hasChanges = true; + } + + if (persona.systemPrompt !== (agent.systemPrompt ?? "")) { + input.systemPrompt = persona.systemPrompt; + hasChanges = true; + } + + if ((persona.model ?? null) !== (agent.model ?? null)) { + input.model = persona.model; + hasChanges = true; + } + + if (!stringRecordEqual(persona.envVars, agent.envVars)) { + input.envVars = persona.envVars; + hasChanges = true; + } + + const runtimeChanged = + options.previousPersona !== undefined && + options.previousPersona.runtime !== persona.runtime; + const runtime = runtimeChanged + ? options.runtimes?.find((candidate) => candidate.id === persona.runtime) + : undefined; + if (runtime?.command) { + if (runtime.command !== agent.agentCommand) { + input.agentCommand = runtime.command; + hasChanges = true; + } + + if (!stringArrayEqual(runtime.defaultArgs, agent.agentArgs)) { + input.agentArgs = [...runtime.defaultArgs]; + hasChanges = true; + } + + const mcpCommand = runtime.mcpCommand ?? ""; + if (mcpCommand !== agent.mcpCommand) { + input.mcpCommand = mcpCommand; + hasChanges = true; + } + } + + return hasChanges ? input : null; +} + +function stringArrayEqual(left: readonly string[], right: readonly string[]) { + if (left.length !== right.length) return false; + + return left.every((value, index) => value === right[index]); +} + +function stringRecordEqual( + left: Record, + right: Record, +) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + + return leftKeys.every((key) => left[key] === right[key]); +} + +export function useRetainedPersona( + sourcePersona: AgentPersona | undefined, + profileIdentityKey: string, +) { + const [retainedPersona, setRetainedPersona] = React.useState<{ + key: string; + persona: AgentPersona; + } | null>(null); + + React.useEffect(() => { + if (!sourcePersona) return; + setRetainedPersona({ key: profileIdentityKey, persona: sourcePersona }); + }, [profileIdentityKey, sourcePersona]); + + return ( + sourcePersona ?? + (retainedPersona?.key === profileIdentityKey + ? retainedPersona.persona + : undefined) + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx new file mode 100644 index 000000000..edb3a14dc --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx @@ -0,0 +1,67 @@ +import type { + AcpRuntimeCatalogEntry, + AgentPersona, + CreatePersonaInput, + UpdatePersonaInput, +} from "@/shared/api/types"; +import { PersonaDeleteDialog } from "@/features/agents/ui/PersonaDeleteDialog"; +import { PersonaDialog } from "@/features/agents/ui/PersonaDialog"; +import type { PersonaDialogState } from "@/features/agents/ui/personaDialogState"; + +export function UserProfilePersonaDialogs({ + createError, + isPending, + personaDialogState, + personaToDelete, + runtimes, + runtimesLoading, + updateError, + onCloseDelete, + onCloseDialog, + onConfirmDelete, + onSubmit, +}: { + createError: Error | null; + isPending: boolean; + personaDialogState: PersonaDialogState | null; + personaToDelete: AgentPersona | null; + runtimes: AcpRuntimeCatalogEntry[]; + runtimesLoading: boolean; + updateError: Error | null; + onCloseDelete: () => void; + onCloseDialog: () => void; + onConfirmDelete: (persona: AgentPersona) => void; + onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; +}) { + return ( + <> + { + if (!open) { + onCloseDialog(); + } + }} + onSubmit={onSubmit} + open={personaDialogState !== null} + submitLabel={personaDialogState?.submitLabel ?? "Save"} + title={personaDialogState?.title ?? "Agent"} + /> + { + if (!open) { + onCloseDelete(); + } + }} + open={personaToDelete !== null} + persona={personaToDelete} + /> + + ); +} diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx index c62af561a..eea5f354b 100644 --- a/desktop/src/shared/context/ProfilePanelContext.tsx +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -1,23 +1,32 @@ import * as React from "react"; +import type { AgentPersona } from "@/shared/api/types"; + type ProfilePanelContextValue = { openProfilePanel: ((pubkey: string) => void) | null; + openPersonaProfilePanel: ((persona: AgentPersona) => void) | null; }; const ProfilePanelContext = React.createContext({ openProfilePanel: null, + openPersonaProfilePanel: null, }); export function ProfilePanelProvider({ children, onOpenProfilePanel, + onOpenPersonaProfilePanel, }: { children: React.ReactNode; onOpenProfilePanel: (pubkey: string) => void; + onOpenPersonaProfilePanel?: (persona: AgentPersona) => void; }) { const value = React.useMemo( - () => ({ openProfilePanel: onOpenProfilePanel }), - [onOpenProfilePanel], + () => ({ + openProfilePanel: onOpenProfilePanel, + openPersonaProfilePanel: onOpenPersonaProfilePanel ?? null, + }), + [onOpenPersonaProfilePanel, onOpenProfilePanel], ); return (