From f2db5bbb5095b8f9c48f4efd75115be775e2139c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 23 Jun 2026 17:11:16 +0100 Subject: [PATCH 01/11] Move agent management into profile sidebar --- .../src/features/agents/ui/AgentGroupRows.tsx | 21 +- .../src/features/agents/ui/AgentsScreen.tsx | 68 +- desktop/src/features/agents/ui/AgentsView.tsx | 33 +- .../features/agents/ui/ManagedAgentRow.tsx | 218 +---- .../agents/ui/UnifiedAgentsSection.tsx | 50 +- .../profile/ui/UserProfileAgentActions.tsx | 105 ++ .../features/profile/ui/UserProfilePanel.tsx | 897 +++++++++++++---- .../profile/ui/UserProfilePanelDeletion.ts | 159 ++++ .../profile/ui/UserProfilePanelFields.tsx | 491 ++++++++++ .../ui/UserProfilePanelPersonaSubmit.test.mjs | 138 +++ .../ui/UserProfilePanelPersonaSubmit.ts | 135 +++ .../profile/ui/UserProfilePanelSections.tsx | 900 +++++++++--------- .../profile/ui/UserProfilePanelUtils.test.mjs | 176 ++++ .../profile/ui/UserProfilePanelUtils.ts | 326 +++++++ .../profile/ui/UserProfilePersonaDialogs.tsx | 67 ++ .../shared/context/ProfilePanelContext.tsx | 13 +- 16 files changed, 2869 insertions(+), 928 deletions(-) create mode 100644 desktop/src/features/profile/ui/UserProfileAgentActions.tsx create mode 100644 desktop/src/features/profile/ui/UserProfilePanelDeletion.ts create mode 100644 desktop/src/features/profile/ui/UserProfilePanelFields.tsx create mode 100644 desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs create mode 100644 desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts create mode 100644 desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs create mode 100644 desktop/src/features/profile/ui/UserProfilePanelUtils.ts create mode 100644 desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx 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 44088d490..f80207beb 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -22,8 +22,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( @@ -83,11 +85,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(); }} @@ -97,22 +94,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} @@ -136,13 +124,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 8769737c6..c7de8fafb 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -1,20 +1,6 @@ import * as React from "react"; -import { - AlertTriangle, - ChevronDown, - ChevronRight, - Clipboard, - Ellipsis, - FileText, - Pencil, - Play, - Power, - Square, - Trash2, - UserPlus, -} from "lucide-react"; -import { toast } from "sonner"; +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -29,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, @@ -54,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; @@ -72,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; @@ -181,18 +148,14 @@ export function ManagedAgentRow({ )}
- - onSelectLogAgent(pubkey)} - onStart={onStart} - onStop={onStop} - onToggleStartOnAppLaunch={onToggleStartOnAppLaunch} - /> +
@@ -274,12 +237,6 @@ function AgentSummary({ Remote deployment )} - {agent.personaOutOfDate ? ( -

- Persona updated since this agent was created. Respawn to apply the - new configuration. -

- ) : null} {channelNames.length > 0 ? (
{channelNames.map((channel) => ( @@ -297,6 +254,12 @@ function AgentSummary({ ))}
) : null} + {agent.personaOutOfDate ? ( +

+ Persona updated since this agent was created. Respawn to apply the + new configuration. +

+ ) : null} {activeWorkingChannels.length > 0 ? (
{activeWorkingChannels.map((channel) => ( @@ -414,151 +377,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 - - - - - {editOpen ? ( - - ) : null} - - ); -} - 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 335202c22..013f1914e 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,86 +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; - onOpenProfile?: (pubkey: 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, @@ -141,9 +118,10 @@ export function UserProfilePanel({ onResetWidth, onResizeStart, onViewChange, + persona, pubkey, splitPaneClamp = false, - view, + view: controlledView, widthPx, }: UserProfilePanelProps) { const isOverlay = useIsThreadPanelOverlay(); @@ -151,75 +129,135 @@ 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 ownerPubkey = profile?.ownerPubkey ?? null; - const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); - 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); - // Does THIS desktop hold the agent's seckey? Gates edit (which needs the key) - // and grants owner access when the agent is managed locally. - const isOwner = useIsManagedAgent(isBot ? pubkey : null); - // Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This - // is the right signal for viewing owner-scoped data (activity feed, memory): - // the relay routes and the client decrypts those frames with the owner's OWN - // key, so the agent's seckey is never needed. Computed here (before the gates - // that consume it) so visibility keys off declared ownership, not key custody. + const isBot = Boolean(relayAgent || managedAgent || resolvedPersona); + const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null); + const isOwner = resolvedPersona ? true : managedAgentOwner; + const ownerPubkey = profile?.ownerPubkey ?? null; + const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); const isCurrentUserOwner = currentPubkey !== undefined && ownerPubkey !== null && ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(); - // The viewer may see owner-scoped data if they declared-own the agent OR they - // manage it locally (older agents may not advertise an owner pubkey). Every - // real boundary is server-side, so this only controls what UI we paint. const viewerIsOwner = isCurrentUserOwner || isOwner === true; - // Populate the active-turns store for this agent so useActiveAgentTurns works - // even if the Agents page hasn't been visited yet. - const bridgeAgents = React.useMemo( - () => - managedAgent - ? [{ pubkey: managedAgent.pubkey, status: managedAgent.status }] - : [], - [managedAgent], - ); - // The observer bridge subscribes on the OWNER's own pubkey and decrypts the - // agent's telemetry with the owner's key — no agent seckey needed. It only - // decrypts frames whose agent pubkey is "known", and only subscribes when an - // agent is running/deployed. For a remote agent we own but don't manage - // locally, `managedAgent` is undefined, so we seed the bridge from the relay - // agent (treated as "deployed") when the viewer is the declared owner. This - // mirrors what the composer-area ingress already does in ChannelScreen. + // Populate the observer and active-turn stores for this agent so profile + // activity works even if the Agents page hasn't been visited yet. const observerBridgeAgents = React.useMemo(() => { if (managedAgent) { return [{ pubkey: managedAgent.pubkey, status: managedAgent.status }]; @@ -228,23 +266,48 @@ export function UserProfilePanel({ return [ { pubkey: relayAgent.pubkey, - status: "deployed" as ManagedAgent["status"], + status: "deployed" as const, }, ]; } return []; }, [managedAgent, relayAgent, viewerIsOwner]); - useActiveAgentTurnsBridge(bridgeAgents); + useActiveAgentTurnsBridge(observerBridgeAgents); useManagedAgentObserverBridge(observerBridgeAgents); - const canEditAgent = isOwner === true && managedAgent !== undefined; - const memoryQuery = useAgentMemoryQuery(pubkey, { - enabled: viewerIsOwner, + const canEditAgent = + isOwner === true && + (managedAgent !== undefined || + (resolvedPersona !== undefined && !resolvedPersona.isBuiltIn)); + const memoryQuery = useAgentMemoryQuery(effectivePubkey, { + enabled: viewerIsOwner && Boolean(effectivePubkey), }); const isSelf = - currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); - const canViewActivity = viewerIsOwner && Boolean(onOpenAgentSession); + currentPubkey !== undefined && + pubkeyLower.length > 0 && + pubkeyLower === currentPubkey.toLowerCase(); + const canViewActivity = + viewerIsOwner && 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, ) ?? @@ -269,64 +332,384 @@ 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, + ); - const handleOpenActivity = React.useCallback(() => { - onClose(); - onOpenAgentSession?.(pubkey); - }, [onClose, onOpenAgentSession, pubkey]); + for (const warning of warnings) { + toast.warning(warning); + } - const handleOpenChannel = React.useCallback( - (channelId: string) => { - void goChannel(channelId); + 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; }, - [goChannel], + [ + availableRuntimesQuery.data, + createAgentMutation.mutateAsync, + managedAgentsQuery.refetch, + relayAgentsQuery.refetch, + ], ); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); - const ownerHandle = React.useMemo(() => { - if (ownerPubkey) { - const ownerProfile = ownerProfileQuery.data; - return ( - ownerProfile?.nip05Handle?.trim() || - ownerProfile?.displayName?.trim() || - truncatePubkey(ownerPubkey) + 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; - if (currentPubkey === undefined || isOwner !== true) { - return null; + 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 currentProfile = currentProfileQuery.data; - return ( - currentProfile?.nip05Handle?.trim() || - currentProfile?.displayName?.trim() || - truncatePubkey(currentPubkey) - ); + 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); }, [ - currentProfileQuery.data, - currentPubkey, - isOwner, - ownerProfileQuery.data, - ownerPubkey, + 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?.(effectivePubkey); + }, [effectivePubkey, onClose, onOpenAgentSession]); + + const handleOpenChannel = React.useCallback( + (channelId: string) => { + void goChannel(channelId); + }, + [goChannel], + ); + + const displayName = resolveProfileDisplayName({ + persona: resolvedPersona, + profile, + pubkey: effectivePubkey, + }); + const ownerHandle = resolveOwnerHandle( + ownerPubkey ? ownerProfileQuery.data : currentProfileQuery.data, + ownerPubkey ?? currentPubkey, + ); const ownerDisplayName = ownerHandle ? isCurrentUserOwner || (!ownerPubkey && isOwner === true) ? `${ownerHandle} (you)` : ownerHandle : 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, + ownerAvatarUrl: ownerProfileQuery.data?.avatarUrl ?? null, + ownerDisplayName, + ownerHandle, + ownerPubkey, + onOpenOwner: + ownerPubkey && onOpenProfile + ? () => onOpenProfile(ownerPubkey) + : undefined, + persona: resolvedPersona, + presenceLoaded: presenceQuery.isSuccess, + presenceStatus, + profile, + pubkey: effectivePubkey, + relayAgent, + }); const headerLeftContent = ( @@ -335,7 +718,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" @@ -343,18 +726,16 @@ export function UserProfilePanel({ ) : null} - {panelTitle} + + {PROFILE_PANEL_VIEW_TITLES[view]} + ); const headerActions = (
- {view === "memories" && viewerIsOwner ? ( - + {view === "memories" && viewerIsOwner && effectivePubkey ? ( + ) : null}
); @@ -440,7 +898,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 ( <> @@ -452,6 +947,8 @@ export function UserProfilePanel({ {profileBody} {editAgentDialog} + {addAgentToChannelDialog} + {personaDialogs} ); } @@ -495,7 +992,7 @@ export function UserProfilePanel({ {!isOverlay ? ( + ); +} + +function ProfilePersonaPrimaryActions({ + canEditAgent, + disabled, + onEditAgent, + onStartAgent, +}: { + canEditAgent: boolean; + disabled: boolean; + onEditAgent: () => void; + onStartAgent: () => void; +}) { + return ( +
+ + {canEditAgent ? ( + + ) : null}
); } @@ -530,343 +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({ - includeOperationalFields, - managedAgent, - ownerDisplayName, - ownerAvatarUrl, - ownerHandle, - ownerPubkey, - onOpenOwner, - presenceLoaded, - presenceStatus, - relayAgent, -}: { - includeOperationalFields: boolean; - managedAgent: ManagedAgent | undefined; - ownerDisplayName: string | null; - ownerAvatarUrl: string | null; - ownerHandle: string | null; - ownerPubkey: string | null; - onOpenOwner?: () => void; - presenceLoaded: boolean; - presenceStatus: "online" | "away" | "offline" | undefined; - relayAgent: RelayAgent | undefined; -}): ProfileField[] { - const fields: ProfileField[] = []; - - if (ownerDisplayName) { - fields.push({ - copyValue: onOpenOwner - ? undefined - : (ownerPubkey ?? ownerHandle ?? undefined), - displayValue: ownerDisplayName, - displayNode: onOpenOwner ? ( - - ) : undefined, - icon: UserRound, - label: "Owned by", - testId: "user-profile-owned-by", - }); - } - - if (!includeOperationalFields) { - return fields; - } - - 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; @@ -875,8 +694,9 @@ function ProfileIngressRow({ }) { return ( @@ -898,18 +723,18 @@ function ProfileIngressRow({ export function MemoryFocusedView({ agentPubkey, - viewerIsOwner, + isOwner, }: { agentPubkey: string; - viewerIsOwner: boolean | undefined; + isOwner: boolean | undefined; }) { - if (viewerIsOwner !== true) { + if (isOwner !== true) { return null; } return (
- +
); } @@ -920,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..026c2dbc4 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -0,0 +1,326 @@ +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; + onOpenProfile?: (pubkey: 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, + ownerPubkey: 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 ( From bc0dcb3ed02791068ccd4ebdc21f0e6fe372932c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 13:59:08 +0100 Subject: [PATCH 02/11] Fix profile sidebar review feedback --- .../src/app/routes/channels.$channelId.tsx | 10 +++-- desktop/src/app/routes/pulse.tsx | 11 ++--- .../agents/observerRelayStore.test.mjs | 45 +++++++++++++++++++ .../src/features/agents/observerRelayStore.ts | 18 +++++--- .../ui/useChannelPanelHistoryState.ts | 11 +++-- .../features/profile/ui/UserProfilePanel.tsx | 6 ++- .../profile/ui/UserProfilePanelSections.tsx | 2 +- .../profile/ui/UserProfilePanelUtils.test.mjs | 2 - desktop/src/features/pulse/ui/PulseScreen.tsx | 10 ++--- 9 files changed, 84 insertions(+), 31 deletions(-) create mode 100644 desktop/src/features/agents/observerRelayStore.test.mjs diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..1607684fd 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -1,13 +1,17 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { agentSession?: string; messageId?: string; profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; thread?: string; threadRootId?: string; }; @@ -16,8 +20,8 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function profileViewValue(value: unknown): "memories" | "channels" | undefined { - return value === "memories" || value === "channels" ? value : undefined; +function profileViewValue(value: unknown): ProfilePanelView | undefined { + return parseProfilePanelView(value) ?? undefined; } function validateChannelSearch( diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e1a5a001e..949b56d0b 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -11,7 +15,7 @@ const PulseScreen = React.lazy(async () => { type PulseRouteSearch = { profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; }; function validatePulseSearch( @@ -22,10 +26,7 @@ function validatePulseSearch( typeof search.profile === "string" && search.profile.length > 0 ? search.profile : undefined, - profileView: - search.profileView === "memories" || search.profileView === "channels" - ? search.profileView - : undefined, + profileView: parseProfilePanelView(search.profileView) ?? undefined, }; } diff --git a/desktop/src/features/agents/observerRelayStore.test.mjs b/desktop/src/features/agents/observerRelayStore.test.mjs new file mode 100644 index 000000000..93c04733d --- /dev/null +++ b/desktop/src/features/agents/observerRelayStore.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + isKnownAgentPubkey, + registerKnownAgentPubkeys, + resetAgentObserverStore, + unregisterKnownAgentPubkeys, +} from "./observerRelayStore.ts"; + +const AGENT_A = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const AGENT_B = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const AGENT_C = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +describe("observerRelayStore known agent registrations", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("unions known agents from multiple bridge registrations", () => { + const agentsPage = Symbol("agents-page"); + const profilePanel = Symbol("profile-panel"); + + registerKnownAgentPubkeys(agentsPage, [AGENT_A, AGENT_B]); + registerKnownAgentPubkeys(profilePanel, [AGENT_C]); + + assert.equal(isKnownAgentPubkey(AGENT_A), true); + assert.equal(isKnownAgentPubkey(AGENT_B), true); + assert.equal(isKnownAgentPubkey(AGENT_C), true); + + registerKnownAgentPubkeys(profilePanel, []); + + assert.equal(isKnownAgentPubkey(AGENT_A), true); + assert.equal(isKnownAgentPubkey(AGENT_B), true); + assert.equal(isKnownAgentPubkey(AGENT_C), false); + + unregisterKnownAgentPubkeys(agentsPage); + + assert.equal(isKnownAgentPubkey(AGENT_A), false); + assert.equal(isKnownAgentPubkey(AGENT_B), false); + }); +}); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 6f3370de0..6e9251a46 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -47,7 +47,7 @@ const snapshotByAgent = new Map(); // We key each subscriber's contribution in `knownAgentsBySubscription` and // recompute the union, so co-mounted callers no longer clobber each other. const knownAgentPubkeys = new Set(); -const knownAgentsBySubscription = new Map>(); +const knownAgentsBySubscription = new Map>(); function recomputeKnownAgentPubkeys() { knownAgentPubkeys.clear(); @@ -58,8 +58,8 @@ function recomputeKnownAgentPubkeys() { } } -function registerKnownAgents( - subscriptionId: string, +export function registerKnownAgentPubkeys( + subscriptionId: string | symbol, pubkeys: readonly string[], ) { knownAgentsBySubscription.set( @@ -69,12 +69,16 @@ function registerKnownAgents( recomputeKnownAgentPubkeys(); } -function unregisterKnownAgents(subscriptionId: string) { +export function unregisterKnownAgentPubkeys(subscriptionId: string | symbol) { if (knownAgentsBySubscription.delete(subscriptionId)) { recomputeKnownAgentPubkeys(); } } +export function isKnownAgentPubkey(pubkey: string) { + return knownAgentPubkeys.has(normalizePubkey(pubkey)); +} + let connectionState: ConnectionState = "idle"; let errorMessage: string | null = null; let unsubscribeRelay: (() => Promise) | null = null; @@ -176,7 +180,7 @@ async function handleRelayObserverEvent( // Verify agent is known/trusted before decrypting. // Silently drop events from agents we are not managing. - if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { + if (!isKnownAgentPubkey(agentPubkey)) { return; } @@ -326,9 +330,9 @@ export function useManagedAgentObserverBridge( // own agent list. The store recomputes the union across all subscribers, so // a co-mounted caller no longer wipes out this caller's agents. React.useEffect(() => { - registerKnownAgents(subscriptionId, agentPubkeys); + registerKnownAgentPubkeys(subscriptionId, agentPubkeys); return () => { - unregisterKnownAgents(subscriptionId); + unregisterKnownAgentPubkeys(subscriptionId); }; }, [subscriptionId, agentPubkeys]); diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 588a1e6df..e51041f3c 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanel"; +import { + profilePanelViewFromSearch, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { type HistorySearchSetterOptions, useHistorySearchState, @@ -36,10 +39,6 @@ const CHANNEL_SEARCH_KEYS = [ const CHANNEL_MANAGEMENT_OPEN_VALUE = "1"; -function asProfilePanelView(value: string | null): ProfilePanelView { - return value === "memories" || value === "channels" ? value : "summary"; -} - export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -88,7 +87,7 @@ export function useChannelPanelHistoryState() { openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, - profilePanelView: asProfilePanelView(values.profileView), + profilePanelView: profilePanelViewFromSearch(values.profileView), setChannelManagementOpen, setOpenAgentSessionPubkey, setOpenThreadHeadId, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 013f1914e..813c32886 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -735,7 +735,11 @@ export function UserProfilePanel({ const headerActions = (
{view === "memories" && viewerIsOwner && effectivePubkey ? ( - + ) : null}
); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index d7839790d..287e944d5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -85,7 +85,6 @@ 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" }, @@ -121,7 +120,6 @@ test("personaManagedAgentUpdate maps changed persona runtime to linked agent com { pubkey: "deadbeef".repeat(8), name: "Fizz Prime", - avatarUrl: null, systemPrompt: "New prompt", model: "new-model", envVars: { NEW_KEY: "2" }, diff --git a/desktop/src/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 1baef3b14..1ed60c0c9 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -3,9 +3,10 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { + profilePanelViewFromSearch, type ProfilePanelView, - UserProfilePanel, -} from "@/features/profile/ui/UserProfilePanel"; +} from "@/features/profile/ui/UserProfilePanelUtils"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { PulseView } from "@/features/pulse/ui/PulseView"; import { useIdentityQuery } from "@/shared/api/hooks"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; @@ -18,10 +19,7 @@ export function PulseScreen() { const identityQuery = useIdentityQuery(); const { applyPatch, values } = useHistorySearchState(PULSE_PANEL_SEARCH_KEYS); const profilePanelPubkey = values.profile; - const profilePanelView: ProfilePanelView = - values.profileView === "memories" || values.profileView === "channels" - ? values.profileView - : "summary"; + const profilePanelView = profilePanelViewFromSearch(values.profileView); const handleOpenProfilePanel = React.useCallback( (pubkey: string) => applyPatch({ profile: pubkey, profileView: null }), [applyPatch], From ceb98608c2303ea9d5975e8ab3fb510263d2690c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 14:18:08 +0100 Subject: [PATCH 03/11] Fix profile sidebar CI failures --- .../features/profile/ui/UserProfilePanel.tsx | 57 ++++----------- .../ui/UserProfilePanelHeaderControls.tsx | 72 +++++++++++++++++++ desktop/tests/e2e/mentions.spec.ts | 2 +- 3 files changed, 88 insertions(+), 43 deletions(-) create mode 100644 desktop/src/features/profile/ui/UserProfilePanelHeaderControls.tsx diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 813c32886..f7beecea4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { ArrowLeft, X } from "lucide-react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -7,7 +6,6 @@ import { useAgentMemoryQuery, useIsManagedAgent, } from "@/features/agent-memory/hooks"; -import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; import { type AttachManagedAgentToChannelResult, useAcpRuntimesQuery, @@ -85,8 +83,6 @@ import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth"; import { AuxiliaryPanelHeader, - AuxiliaryPanelHeaderGroup, - AuxiliaryPanelTitle, auxiliaryPanelContentPaddingClass, } from "@/shared/layout/AuxiliaryPanelHeader"; import { cn } from "@/shared/lib/cn"; @@ -97,13 +93,16 @@ import type { CreatePersonaInput, UpdatePersonaInput, } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; import { OverlayPanelBackdrop, PANEL_BASE_CLASS, PANEL_OVERLAY_CLASS, PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; +import { + UserProfilePanelHeaderActions, + UserProfilePanelHeaderLeft, +} from "./UserProfilePanelHeaderControls"; export type { ProfilePanelView }; @@ -712,46 +711,20 @@ export function UserProfilePanel({ }); const headerLeftContent = ( - - {view !== "summary" ? ( - - ) : null} - - {PROFILE_PANEL_VIEW_TITLES[view]} - - + setView("summary")} + /> ); const headerActions = ( -
- {view === "memories" && viewerIsOwner && effectivePubkey ? ( - - ) : null} - -
+ ); const profileBody = ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelHeaderControls.tsx b/desktop/src/features/profile/ui/UserProfilePanelHeaderControls.tsx new file mode 100644 index 000000000..f590bdecb --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelHeaderControls.tsx @@ -0,0 +1,72 @@ +import { ArrowLeft, X } from "lucide-react"; + +import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; +import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanelUtils"; +import { + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, +} from "@/shared/layout/AuxiliaryPanelHeader"; +import { Button } from "@/shared/ui/button"; + +export function UserProfilePanelHeaderLeft({ + title, + view, + onBack, +}: { + title: string; + view: ProfilePanelView; + onBack: () => void; +}) { + return ( + + {view !== "summary" ? ( + + ) : null} + {title} + + ); +} + +export function UserProfilePanelHeaderActions({ + effectivePubkey, + view, + viewerIsOwner, + onClose, +}: { + effectivePubkey: string | null; + view: ProfilePanelView; + viewerIsOwner: boolean; + onClose: () => void; +}) { + return ( +
+ {view === "memories" && viewerIsOwner && effectivePubkey ? ( + + ) : null} + +
+ ); +} diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c969bd2a0..25ec481fc 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1034,7 +1034,7 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText("deadbeef"); + await expect(panel).toContainText("npub1mock..."); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ From f11a1c68e257f1c91dad9e7cd3e15606b31abbd9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 14:23:15 +0100 Subject: [PATCH 04/11] Address profile sidebar review feedback --- .../features/profile/ui/UserProfilePanel.tsx | 15 +++----- .../ui/UserProfilePanelPersonaSubmit.test.mjs | 12 ++++++ .../ui/UserProfilePanelPersonaSubmit.ts | 3 ++ .../profile/ui/UserProfilePanelUtils.test.mjs | 38 ++++++++++++++++--- .../profile/ui/UserProfilePanelUtils.ts | 30 ++++++++++++++- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f7beecea4..4b6e7a1bf 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -151,16 +151,13 @@ export function UserProfilePanel({ 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); + if (!pubkey) { + return undefined; } - return undefined; - }, [managedAgentsQuery.data, persona, pubkey]); + const agents = managedAgentsQuery.data ?? []; + const pubkeyLower = pubkey.toLowerCase(); + return agents.find((agent) => agent.pubkey.toLowerCase() === pubkeyLower); + }, [managedAgentsQuery.data, pubkey]); const resolvedPersonaFromSource = React.useMemo(() => { const personaId = persona?.id ?? managedAgent?.personaId; if (personaId) { diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs index 406cc3f09..7f264748c 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs @@ -136,3 +136,15 @@ test("validateLinkedAgentRuntimeEdit allows unchanged or unlinked runtime prefer null, ); }); + +test("validateLinkedAgentRuntimeEdit allows clearing linked runtime preference", () => { + assert.equal( + validateLinkedAgentRuntimeEdit({ + input: updateInput({ runtime: undefined }), + managedAgent: agent(), + previousPersona: persona({ runtime: "goose" }), + runtimes: [], + }), + null, + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts index eff9a1af1..00e2ee817 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts @@ -53,6 +53,9 @@ export function validateLinkedAgentRuntimeEdit({ if (previousRuntime === nextRuntime) { return null; } + if (!nextRuntime) { + return null; + } const runtime = runtimes?.find((candidate) => candidate.id === nextRuntime); if (runtime?.availability === "available" && runtime.command) { diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 287e944d5..9af5fe3bc 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -113,16 +113,26 @@ test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { test("personaManagedAgentUpdate maps changed persona runtime to linked agent commands", () => { assert.deepEqual( - personaManagedAgentUpdate(agent(), persona({ runtime: "claude" }), { - previousPersona: persona({ runtime: "goose" }), - runtimes: [runtime()], - }), + personaManagedAgentUpdate( + agent({ envVars: { SHARED: "old", AGENT_ONLY: "keep" } }), + persona({ + runtime: "claude", + envVars: { SHARED: "new", PERSONA_ONLY: "set" }, + }), + { + previousPersona: persona({ + runtime: "goose", + envVars: { SHARED: "old" }, + }), + runtimes: [runtime()], + }, + ), { pubkey: "deadbeef".repeat(8), name: "Fizz Prime", systemPrompt: "New prompt", model: "new-model", - envVars: { NEW_KEY: "2" }, + envVars: { SHARED: "new", PERSONA_ONLY: "set", AGENT_ONLY: "keep" }, agentCommand: "claude", agentArgs: ["mcp", "serve"], mcpCommand: "claude-mcp", @@ -130,6 +140,24 @@ test("personaManagedAgentUpdate maps changed persona runtime to linked agent com ); }); +test("personaManagedAgentUpdate preserves agent env overrides when persona env is unchanged", () => { + assert.deepEqual( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { API_KEY: "agent-secret" }, + }), + persona({ envVars: { API_KEY: "persona-default" } }), + { + previousPersona: persona({ envVars: { API_KEY: "persona-default" } }), + }, + ), + null, + ); +}); + test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => { assert.equal( personaManagedAgentUpdate( diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 026c2dbc4..d5bd0506b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -254,8 +254,13 @@ export function personaManagedAgentUpdate( hasChanges = true; } - if (!stringRecordEqual(persona.envVars, agent.envVars)) { - input.envVars = persona.envVars; + const nextEnvVars = mergedPersonaEnvVarsForAgent( + agent, + persona, + options.previousPersona, + ); + if (!stringRecordEqual(nextEnvVars, agent.envVars)) { + input.envVars = nextEnvVars; hasChanges = true; } @@ -286,6 +291,27 @@ export function personaManagedAgentUpdate( return hasChanges ? input : null; } +function mergedPersonaEnvVarsForAgent( + agent: ManagedAgent, + persona: AgentPersona, + previousPersona: AgentPersona | undefined, +) { + if (!previousPersona) { + return persona.envVars; + } + if (stringRecordEqual(persona.envVars, previousPersona.envVars)) { + return agent.envVars; + } + + const nextEnvVars = { ...persona.envVars }; + for (const [key, value] of Object.entries(agent.envVars)) { + if (previousPersona.envVars[key] !== value) { + nextEnvVars[key] = value; + } + } + return nextEnvVars; +} + function stringArrayEqual(left: readonly string[], right: readonly string[]) { if (left.length !== right.length) return false; From 2e7b264d3567e187d68757923a50372bd891a78d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 14:33:35 +0100 Subject: [PATCH 05/11] Preserve agents when deleting persona templates --- desktop/src/features/profile/ui/UserProfilePanel.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 4b6e7a1bf..04762a2bc 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -602,21 +602,17 @@ export function UserProfilePanel({ } 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.", + error instanceof Error ? error.message : "Failed to delete persona.", ); } }, - [deleteManagedAgentsForPersona, deletePersonaMutation.mutateAsync, onClose], + [deletePersonaMutation.mutateAsync, onClose], ); const handleAddedToChannel = React.useCallback( From 7d43e452e1fc1710ea06f62abced4e7421348c96 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 14:49:06 +0100 Subject: [PATCH 06/11] Update mesh compute test for profile sidebar --- desktop/tests/e2e/mesh-compute.spec.ts | 32 +++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index 05ba8e0f2..2f3385ba3 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -64,15 +64,22 @@ async function setMesh( }, mesh); } -async function openManagedAgentActions( +async function openManagedAgentProfile( page: import("@playwright/test").Page, pubkey: string, ) { - const trigger = page.getByTestId(`managed-agent-actions-${pubkey}`); - await trigger.scrollIntoViewIfNeeded(); - await trigger.focus(); - await trigger.press("Enter"); - await expect(trigger).toHaveAttribute("data-state", "open"); + const row = page.getByTestId(`managed-agent-${pubkey}`); + await row.getByRole("button", { name: "Manage" }).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); +} + +async function clickManagedAgentPrimaryAction( + page: import("@playwright/test").Page, + label: string, +) { + const action = page.getByTestId("user-profile-agent-primary-action"); + await expect(action).toContainText(label); + await action.click(); } async function openNewAgentMenu(page: import("@playwright/test").Page) { @@ -330,8 +337,8 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a 0, ); - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Stop" }).click(); + await openManagedAgentProfile(page, pubkey); + await clickManagedAgentPrimaryAction(page, "Stop"); await expect .poll(async () => await commands(page)) .toContain("stop_managed_agent"); @@ -339,22 +346,19 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a // With a live serve target for the model, manual restart goes through: // the backend preflight re-resolves the target and the agent starts. - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Spawn" }).click(); + await clickManagedAgentPrimaryAction(page, "Respawn"); await expect .poll(async () => await commands(page)) .toContain("start_managed_agent"); await expect(row).toContainText("running"); - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Stop" }).click(); + await clickManagedAgentPrimaryAction(page, "Stop"); await expect(row).toContainText("stopped"); // Without a live serve target, the backend preflight rejects the start // with an actionable error, surfaced as a toast; the agent stays stopped. await setMesh(page, { models: [] }); - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Spawn" }).click(); + await clickManagedAgentPrimaryAction(page, "Respawn"); await expect( page From 1bdf13cc2c78bed329548ef886c5b5656dbb7951 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 15:10:53 +0100 Subject: [PATCH 07/11] Fix profile sidebar review feedback --- .../UserProfileCreatedAgentSecretDialog.tsx | 22 ++++++++++++ .../features/profile/ui/UserProfilePanel.tsx | 8 ++++- .../profile/ui/UserProfilePanelUtils.test.mjs | 34 +++++++++++++++++++ .../profile/ui/UserProfilePanelUtils.ts | 11 +++++- 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/profile/ui/UserProfileCreatedAgentSecretDialog.tsx diff --git a/desktop/src/features/profile/ui/UserProfileCreatedAgentSecretDialog.tsx b/desktop/src/features/profile/ui/UserProfileCreatedAgentSecretDialog.tsx new file mode 100644 index 000000000..05c66ab12 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileCreatedAgentSecretDialog.tsx @@ -0,0 +1,22 @@ +import { SecretRevealDialog } from "@/features/agents/ui/SecretRevealDialog"; +import type { CreateManagedAgentResponse } from "@/shared/api/types"; +import React from "react"; + +export function useCreatedAgentSecretReveal() { + const [createdAgent, setCreatedAgent] = + React.useState(null); + + return { + createdAgentSecretDialog: createdAgent ? ( + { + if (!open) { + setCreatedAgent(null); + } + }} + /> + ) : null, + setCreatedAgent, + }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 04762a2bc..1a90da5ff 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -103,6 +103,7 @@ import { UserProfilePanelHeaderActions, UserProfilePanelHeaderLeft, } from "./UserProfilePanelHeaderControls"; +import { useCreatedAgentSecretReveal } from "./UserProfileCreatedAgentSecretDialog"; export type { ProfilePanelView }; @@ -130,6 +131,8 @@ export function UserProfilePanel({ const [internalView, setInternalView] = React.useState("summary"); + const { createdAgentSecretDialog, setCreatedAgent } = + useCreatedAgentSecretReveal(); const view = controlledView ?? internalView; const setView = React.useCallback( (nextView: ProfilePanelView, options?: { replace?: boolean }) => { @@ -449,6 +452,7 @@ export function UserProfilePanel({ try { const created = await createManagedAgentForPersona(resolvedPersona); + setCreatedAgent(created); if (created.spawnError) { toast.error(created.spawnError); } else { @@ -462,7 +466,7 @@ export function UserProfilePanel({ error instanceof Error ? error.message : "Failed to start agent.", ); } - }, [createManagedAgentForPersona, resolvedPersona]); + }, [createManagedAgentForPersona, resolvedPersona, setCreatedAgent]); const handleToggleAgentAutoStart = React.useCallback(async () => { if (managedAgent?.backend.type !== "local") return; @@ -918,6 +922,7 @@ export function UserProfilePanel({ {editAgentDialog} {addAgentToChannelDialog} + {createdAgentSecretDialog} {personaDialogs} ); @@ -985,6 +990,7 @@ export function UserProfilePanel({ {editAgentDialog} {addAgentToChannelDialog} + {createdAgentSecretDialog} {personaDialogs} ); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 9af5fe3bc..1830741f4 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -179,6 +179,40 @@ test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unch ); }); +test("personaManagedAgentUpdate resets runtime fields when persona runtime is cleared", () => { + assert.deepEqual( + personaManagedAgentUpdate( + agent({ + agentCommand: "claude", + agentArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + }), + persona({ runtime: null }), + { + previousPersona: persona({ runtime: "claude" }), + runtimes: [ + runtime({ + id: "goose", + command: "goose", + defaultArgs: [], + mcpCommand: "", + }), + runtime({ id: "claude" }), + ], + }, + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + }, + ); +}); + test("parseProfilePanelView accepts all profile panel subviews", () => { for (const view of [ "summary", diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index d5bd0506b..d2b71a048 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -268,7 +268,7 @@ export function personaManagedAgentUpdate( options.previousPersona !== undefined && options.previousPersona.runtime !== persona.runtime; const runtime = runtimeChanged - ? options.runtimes?.find((candidate) => candidate.id === persona.runtime) + ? resolvePersonaManagedAgentRuntime(persona.runtime, options.runtimes) : undefined; if (runtime?.command) { if (runtime.command !== agent.agentCommand) { @@ -291,6 +291,15 @@ export function personaManagedAgentUpdate( return hasChanges ? input : null; } +function resolvePersonaManagedAgentRuntime( + runtimeId: string | null | undefined, + runtimes: readonly AcpRuntimeCatalogEntry[] | undefined, +) { + if (!runtimes?.length) return undefined; + if (!runtimeId) return runtimes[0]; + return runtimes.find((candidate) => candidate.id === runtimeId); +} + function mergedPersonaEnvVarsForAgent( agent: ManagedAgent, persona: AgentPersona, From 5802f4fa110f22a162fb36c84fd40c6b3141f288 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 15:22:24 +0100 Subject: [PATCH 08/11] Fix profile sidebar edit and secret reveal --- .../features/profile/ui/UserProfilePanel.tsx | 10 ++-- .../ui/UserProfilePanelPersonaSubmit.test.mjs | 51 ++++++++++++++++++- .../ui/UserProfilePanelPersonaSubmit.ts | 3 ++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 1a90da5ff..4c26c7c65 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -346,12 +346,12 @@ export function UserProfilePanel({ }, [effectivePubkey, onClose, onOpenDm]); const handleEditAgent = React.useCallback(() => { - if (resolvedPersona && !resolvedPersona.isBuiltIn) { + if (managedAgent) { + setEditAgentOpen(true); + } else if (resolvedPersona && !resolvedPersona.isBuiltIn) { setPersonaDialogState(editPersonaDialogState(resolvedPersona)); - return; } - setEditAgentOpen(true); - }, [resolvedPersona]); + }, [managedAgent, resolvedPersona]); const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({ @@ -513,6 +513,7 @@ export function UserProfilePanel({ createPersona: createPersonaMutation.mutateAsync, input, managedAgent, + onCreatedAgent: setCreatedAgent, onDone: () => { setPersonaDialogState(null); void personasQuery.refetch(); @@ -527,6 +528,7 @@ export function UserProfilePanel({ createPersonaMutation.mutateAsync, createManagedAgentForPersona, managedAgent, + setCreatedAgent, personasQuery.refetch, resolvedPersona, acpRuntimesQuery.data, diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs index 7f264748c..94c04ed13 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { validateLinkedAgentRuntimeEdit } from "./UserProfilePanelPersonaSubmit.ts"; +import { + submitProfilePersonaDialog, + validateLinkedAgentRuntimeEdit, +} from "./UserProfilePanelPersonaSubmit.ts"; function agent(overrides = {}) { return { @@ -73,6 +76,19 @@ function updateInput(overrides = {}) { }; } +function createInput(overrides = {}) { + return { + displayName: "Fizz", + avatarUrl: undefined, + systemPrompt: "Prompt", + runtime: "goose", + model: undefined, + provider: undefined, + namePool: [], + ...overrides, + }; +} + function runtime(overrides = {}) { return { id: "claude", @@ -148,3 +164,36 @@ test("validateLinkedAgentRuntimeEdit allows clearing linked runtime preference", null, ); }); + +test("submitProfilePersonaDialog reports created agents for secret reveal", async () => { + const createdAgent = { + agent: agent({ name: "Fizz Prime" }), + privateKeyNsec: "nsec1secret", + profileSyncError: null, + spawnError: null, + }; + let revealedAgent = null; + let done = false; + + await submitProfilePersonaDialog({ + createManagedAgentForPersona: async () => createdAgent, + createPersona: async () => persona({ displayName: "Fizz Prime" }), + input: createInput({ displayName: "Fizz Prime" }), + managedAgent: undefined, + onCreatedAgent: (created) => { + revealedAgent = created; + }, + onDone: () => { + done = true; + }, + updateManagedAgent: async () => { + throw new Error("updateManagedAgent should not be called"); + }, + updatePersona: async () => { + throw new Error("updatePersona should not be called"); + }, + }); + + assert.equal(revealedAgent, createdAgent); + assert.equal(done, true); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts index 00e2ee817..a851565b3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts @@ -18,6 +18,7 @@ type SubmitProfilePersonaDialogOptions = { createPersona: (input: CreatePersonaInput) => Promise; input: CreatePersonaInput | UpdatePersonaInput; managedAgent: ManagedAgent | undefined; + onCreatedAgent?: (created: CreateManagedAgentResponse) => void; onDone: () => void; previousPersona?: AgentPersona; runtimes?: readonly AcpRuntimeCatalogEntry[]; @@ -71,6 +72,7 @@ export async function submitProfilePersonaDialog({ createPersona, input, managedAgent, + onCreatedAgent, onDone, previousPersona, runtimes, @@ -108,6 +110,7 @@ export async function submitProfilePersonaDialog({ const persona = await createPersona(input); try { const created = await createManagedAgentForPersona(persona); + onCreatedAgent?.(created); if (created.spawnError) { toast.error( `${persona.displayName} was created, but it did not start: ${created.spawnError}`, From 2f34f8a0f1326d970a2dfed59f3cb579af8cfa00 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 15:46:23 +0100 Subject: [PATCH 09/11] Fix profile runtime and owner metadata --- .../features/profile/ui/UserProfilePanel.tsx | 21 ++++++------ .../profile/ui/UserProfilePanelFields.tsx | 33 +++++++++++-------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 4c26c7c65..9eaadf07b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -88,6 +88,7 @@ import { import { cn } from "@/shared/lib/cn"; import type { AgentPersona, + AcpRuntime, Channel, CreateManagedAgentInput, CreatePersonaInput, @@ -189,15 +190,12 @@ export function UserProfilePanel({ ); const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null; const pubkeyLower = effectivePubkey?.toLowerCase() ?? ""; - const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined); const currentProfileQuery = useProfileQuery(currentPubkey !== undefined); - React.useEffect(() => { if (!effectivePubkey) return; void profileQuery.refetch(); }, [effectivePubkey, profileQuery.refetch]); - const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); const availableRuntimesQuery = useAvailableAcpRuntimes(); const acpRuntimesQuery = useAcpRuntimesQuery(); @@ -311,7 +309,6 @@ export function UserProfilePanel({ (contact) => contact.pubkey.toLowerCase() === pubkeyLower, ) ?? false); - const profileChannels = React.useMemo( () => deriveProfileChannels( @@ -322,7 +319,6 @@ export function UserProfilePanel({ ), [pubkeyLower, relayAgent, managedAgent, channelsQuery.data], ); - const channelIdToName = React.useMemo(() => { const map: Record = {}; for (const channel of channelsQuery.data ?? []) { @@ -330,7 +326,6 @@ export function UserProfilePanel({ } return map; }, [channelsQuery.data]); - const targetKey = effectivePubkey ?? `persona:${resolvedPersona?.id ?? "unknown"}`; const prevTargetKeyRef = React.useRef(targetKey); @@ -344,7 +339,6 @@ export function UserProfilePanel({ onOpenDm?.([effectivePubkey]); onClose(); }, [effectivePubkey, onClose, onOpenDm]); - const handleEditAgent = React.useCallback(() => { if (managedAgent) { setEditAgentOpen(true); @@ -352,7 +346,6 @@ export function UserProfilePanel({ setPersonaDialogState(editPersonaDialogState(resolvedPersona)); } }, [managedAgent, resolvedPersona]); - const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({ channels: channelsQuery.data, @@ -362,17 +355,21 @@ export function UserProfilePanel({ presenceLookup: presenceQuery.data, relayAgents: relayAgentsQuery.data, }); - const createManagedAgentForPersona = React.useCallback( async (personaToStart: AgentPersona) => { - const runtimes = availableRuntimesQuery.data ?? []; + const runtimeCatalogData = availableRuntimesQuery.isLoading + ? await availableRuntimesQuery.refetch() + : { data: availableRuntimesQuery.data }; + const runtimes = (runtimeCatalogData.data ?? []).filter( + (candidate): candidate is AcpRuntime => + candidate.availability === "available", + ); const defaultRuntime = runtimes[0] ?? null; const { runtime, warnings } = resolvePersonaRuntime( personaToStart.runtime, runtimes, defaultRuntime, ); - for (const warning of warnings) { toast.warning(warning); } @@ -404,6 +401,8 @@ export function UserProfilePanel({ }, [ availableRuntimesQuery.data, + availableRuntimesQuery.isLoading, + availableRuntimesQuery.refetch, createAgentMutation.mutateAsync, managedAgentsQuery.refetch, relayAgentsQuery.refetch, diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 709e054d2..530f1d81d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -111,20 +111,19 @@ export function useProfileFieldBuckets({ return React.useMemo(() => { const metadataFields = [ ...buildPublicFields({ pubkey, profile, relayAgent, isBot, persona }), - ...(isOwner === true - ? buildOwnerFields({ - managedAgent, - ownerAvatarUrl, - ownerDisplayName, - ownerHandle, - ownerPubkey, - onOpenOwner, - persona, - presenceLoaded, - presenceStatus, - relayAgent, - }) - : []), + ...buildOwnerFields({ + includeManagementFields: isOwner === true, + managedAgent, + ownerAvatarUrl, + ownerDisplayName, + ownerHandle, + ownerPubkey, + onOpenOwner, + persona, + presenceLoaded, + presenceStatus, + relayAgent, + }), ]; const diagnosticsFields = bucketProfileFields(metadataFields).diagnosticsFields; @@ -225,6 +224,7 @@ export function buildPublicFields({ } export function buildOwnerFields({ + includeManagementFields, managedAgent, ownerAvatarUrl, ownerDisplayName, @@ -236,6 +236,7 @@ export function buildOwnerFields({ presenceStatus, relayAgent, }: { + includeManagementFields: boolean; managedAgent: ManagedAgent | undefined; ownerAvatarUrl: string | null; ownerDisplayName: string | null; @@ -281,6 +282,10 @@ export function buildOwnerFields({ }); } + if (!includeManagementFields) { + return fields; + } + if (managedAgent?.agentCommand) { fields.push({ copyValue: managedAgent.agentCommand, From 627d4cbb41850d51badf85ed9391c84902bc797e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 16:01:58 +0100 Subject: [PATCH 10/11] Fix profile owner and runtime inheritance --- .../src/features/profile/ui/UserProfilePanel.tsx | 13 +++++++------ .../profile/ui/UserProfilePanelUtils.test.mjs | 5 +---- .../features/profile/ui/UserProfilePanelUtils.ts | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 9eaadf07b..97b5ea865 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -649,22 +649,25 @@ export function UserProfilePanel({ onClose(); onOpenAgentSession?.(effectivePubkey); }, [effectivePubkey, onClose, onOpenAgentSession]); - const handleOpenChannel = React.useCallback( (channelId: string) => { void goChannel(channelId); }, [goChannel], ); - const displayName = resolveProfileDisplayName({ persona: resolvedPersona, profile, pubkey: effectivePubkey, }); + const ownerProfile = ownerPubkey + ? ownerProfileQuery.data + : isOwner === true + ? currentProfileQuery.data + : undefined; const ownerHandle = resolveOwnerHandle( - ownerPubkey ? ownerProfileQuery.data : currentProfileQuery.data, - ownerPubkey ?? currentPubkey, + ownerProfile, + ownerPubkey ?? (isOwner === true ? currentPubkey : undefined), ); const ownerDisplayName = ownerHandle ? isCurrentUserOwner || (!ownerPubkey && isOwner === true) @@ -715,7 +718,6 @@ export function UserProfilePanel({ onBack={() => setView("summary")} /> ); - const headerActions = ( ); - const profileBody = (
{ +test("personaManagedAgentUpdate leaves runtime inheritance when persona runtime is cleared", () => { assert.deepEqual( personaManagedAgentUpdate( agent({ @@ -206,9 +206,6 @@ test("personaManagedAgentUpdate resets runtime fields when persona runtime is cl name: "Fizz Prime", systemPrompt: "New prompt", model: "new-model", - agentCommand: "goose", - agentArgs: [], - mcpCommand: "", }, ); }); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index d2b71a048..c4d36d490 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -296,7 +296,7 @@ function resolvePersonaManagedAgentRuntime( runtimes: readonly AcpRuntimeCatalogEntry[] | undefined, ) { if (!runtimes?.length) return undefined; - if (!runtimeId) return runtimes[0]; + if (!runtimeId) return undefined; return runtimes.find((candidate) => candidate.id === runtimeId); } From bcd8b3e6955d4ac6b089df13e26a63c20666ab38 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 16:16:17 +0100 Subject: [PATCH 11/11] Honor fallback runtime on persona start --- desktop/src/features/profile/ui/UserProfilePanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 97b5ea865..98bad227a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -365,7 +365,7 @@ export function UserProfilePanel({ candidate.availability === "available", ); const defaultRuntime = runtimes[0] ?? null; - const { runtime, warnings } = resolvePersonaRuntime( + const { runtime, warnings, isOverridden } = resolvePersonaRuntime( personaToStart.runtime, runtimes, defaultRuntime, @@ -382,6 +382,7 @@ export function UserProfilePanel({ name: personaToStart.displayName, acpCommand: "buzz-acp", agentCommand: runtime.command, + harnessOverride: isOverridden, agentArgs: runtime.defaultArgs, mcpCommand: runtime.mcpCommand ?? "", personaId: personaToStart.id, @@ -809,7 +810,6 @@ export function UserProfilePanel({ {view === "info" ? ( ) : null} - {view === "model" ? (