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

- Loading channels… -

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

+ Loading channels… +

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

+ No visible channel memberships. +

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

- No visible channel memberships. -

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

+ No instruction set. +

+ )} + + {onEdit ? ( + + ) : null} + ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs new file mode 100644 index 000000000..d7839790d --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseProfilePanelView, + personaManagedAgentUpdate, + profilePanelViewFromSearch, +} from "./UserProfilePanelUtils.ts"; + +function agent(overrides = {}) { + return { + pubkey: "deadbeef".repeat(8), + name: "Fizz", + personaId: "persona-1", + relayUrl: "ws://localhost:3000", + acpCommand: "buzz-acp", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 320, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: "Old prompt", + avatarUrl: "app-avatar://old", + model: "old-model", + mcpToolsets: null, + envVars: { OLD_KEY: "1" }, + status: "stopped", + pid: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + logPath: null, + startOnAppLaunch: true, + backend: { type: "local" }, + backendAgentId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function persona(overrides = {}) { + return { + id: "persona-1", + displayName: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + runtime: "goose", + model: "new-model", + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + envVars: { NEW_KEY: "2" }, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function runtime(overrides = {}) { + return { + id: "claude", + label: "Claude Code", + avatarUrl: "app-avatar://claude", + availability: "available", + command: "claude", + binaryPath: "/usr/local/bin/claude", + defaultArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + ...overrides, + }; +} + +test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => { + assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + }); +}); + +test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { + assert.equal( + personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), + null, + ); + assert.equal( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + }), + persona(), + ), + null, + ); +}); + +test("personaManagedAgentUpdate maps changed persona runtime to linked agent commands", () => { + assert.deepEqual( + personaManagedAgentUpdate(agent(), persona({ runtime: "claude" }), { + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime()], + }), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + agentCommand: "claude", + agentArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + }, + ); +}); + +test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => { + assert.equal( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + avatarUrl: null, + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + agentArgs: ["custom"], + }), + persona({ runtime: "goose" }), + { + previousPersona: persona({ runtime: "goose" }), + runtimes: [runtime({ id: "goose", command: "goose" })], + }, + ), + null, + ); +}); + +test("parseProfilePanelView accepts all profile panel subviews", () => { + for (const view of [ + "summary", + "info", + "settings", + "diagnostics", + "model", + "instructions", + "memories", + "channels", + "logs", + ]) { + assert.equal(parseProfilePanelView(view), view); + } +}); + +test("profilePanelViewFromSearch falls back to summary for invalid values", () => { + assert.equal(parseProfilePanelView("missing"), null); + assert.equal(profilePanelViewFromSearch("missing"), "summary"); + assert.equal(profilePanelViewFromSearch(null), "summary"); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts new file mode 100644 index 000000000..c7abfb129 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -0,0 +1,324 @@ +import * as React from "react"; +import type { + AcpRuntimeCatalogEntry, + AgentPersona, + Channel, + ManagedAgent, + Profile, + RelayAgent, + UpdateManagedAgentInput, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type ProfileChannelLink = { + id: string; + name: string; +}; + +export type ProfilePanelView = + | "summary" + | "info" + | "settings" + | "diagnostics" + | "model" + | "instructions" + | "memories" + | "channels" + | "logs"; + +export const PROFILE_PANEL_VIEW_TITLES: Record = { + summary: "Profile", + info: "Agent info", + settings: "Agent settings", + diagnostics: "Diagnostics", + model: "Model", + instructions: "Agent instruction", + memories: "Memories", + channels: "Channels", + logs: "Harness log", +}; + +const PROFILE_PANEL_VIEWS = new Set( + Object.keys(PROFILE_PANEL_VIEW_TITLES) as ProfilePanelView[], +); + +export function parseProfilePanelView(value: unknown): ProfilePanelView | null { + return typeof value === "string" && + PROFILE_PANEL_VIEWS.has(value as ProfilePanelView) + ? (value as ProfilePanelView) + : null; +} + +export function profilePanelViewFromSearch(value: unknown): ProfilePanelView { + return parseProfilePanelView(value) ?? "summary"; +} + +export type UserProfilePanelProps = { + canResetWidth?: boolean; + currentPubkey?: string; + isSinglePanelView?: boolean; + layout?: "standalone" | "split"; + onClose: () => void; + onOpenDm?: (pubkeys: string[]) => void; + onResetWidth?: () => void; + onResizeStart?: (event: React.PointerEvent) => void; + onViewChange?: ( + view: ProfilePanelView, + options?: { replace?: boolean }, + ) => void; + persona?: AgentPersona; + pubkey?: string; + splitPaneClamp?: boolean; + view?: ProfilePanelView; + widthPx: number; +}; + +export function truncatePubkey(pubkey: string) { + if (pubkey.length <= 16) { + return pubkey; + } + + return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; +} + +export function deriveProfileChannels( + pubkeyLower: string, + relayAgent: RelayAgent | undefined, + managedAgent: ManagedAgent | undefined, + channels: Channel[] | undefined, +): ProfileChannelLink[] { + const links = new Map(); + const channelsByName = new Map( + channels?.map((channel) => [channel.name, channel]) ?? [], + ); + + relayAgent?.channels.forEach((name, index) => { + const channel = channelsByName.get(name); + const id = relayAgent.channelIds[index] ?? channel?.id ?? name; + links.set(id, { id, name }); + }); + + if (managedAgent && channels) { + for (const channel of channels) { + const isMember = channel.memberPubkeys.some( + (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, + ); + if (isMember) { + links.set(channel.id, { id: channel.id, name: channel.name }); + } + } + } + + return [...links.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); +} + +export function getRelayAgentChannelIds( + relayAgents: readonly RelayAgent[] | undefined, + agentPubkey: string, +): string[] { + const normalized = normalizePubkey(agentPubkey); + const agent = (relayAgents ?? []).find( + (candidate) => normalizePubkey(candidate.pubkey) === normalized, + ); + return agent?.channelIds ?? []; +} + +export function buildPersonaDraftProfile(persona: AgentPersona): Profile { + return { + pubkey: "", + displayName: persona.displayName, + avatarUrl: persona.avatarUrl, + about: null, + nip05Handle: null, + }; +} + +export function resolvePanelProfile({ + persona, + profile, +}: { + managedAgent: ManagedAgent | undefined; + persona: AgentPersona | undefined; + profile: Profile | undefined; +}): Profile | undefined { + const baseProfile = + profile ?? (persona ? buildPersonaDraftProfile(persona) : undefined); + return withProfileAvatarFallback(baseProfile, [persona?.avatarUrl]); +} + +export function resolveProfileAvatarUrl( + ...candidates: Array +): string | null { + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} + +export function withProfileAvatarFallback( + profile: Profile | undefined, + fallbackAvatarUrls: Array, +): Profile | undefined { + const profileAvatarUrl = normalizeProfileFallbackAvatarUrl( + profile?.avatarUrl, + ); + const avatarUrl = resolveProfileAvatarUrl( + profileAvatarUrl, + ...fallbackAvatarUrls.map((avatarUrl) => + normalizeProfileFallbackAvatarUrl(avatarUrl), + ), + ); + return profile && avatarUrl !== profile.avatarUrl + ? { ...profile, avatarUrl } + : profile; +} + +function normalizeProfileFallbackAvatarUrl( + avatarUrl: string | null | undefined, +): string | null { + const trimmed = avatarUrl?.trim(); + if (!trimmed) return null; + return trimmed; +} + +export function resolveProfileDisplayName({ + persona, + profile, + pubkey, +}: { + persona: AgentPersona | undefined; + profile: Profile | undefined; + pubkey: string | null; +}) { + return ( + profile?.displayName ?? + persona?.displayName ?? + (pubkey ? truncatePubkey(pubkey) : "Agent") + ); +} + +export function resolveOwnerHandle( + profile: Profile | undefined, + currentPubkey: string | undefined, +) { + if (currentPubkey === undefined) { + return null; + } + + return ( + profile?.nip05Handle?.trim() || + profile?.displayName?.trim() || + truncatePubkey(currentPubkey) + ); +} + +export function resolveAgentInstruction( + managedAgent: ManagedAgent | undefined, + persona: AgentPersona | undefined, +) { + return ( + managedAgent?.systemPrompt?.trim() || persona?.systemPrompt.trim() || null + ); +} + +export function personaManagedAgentUpdate( + agent: ManagedAgent, + persona: AgentPersona, + options: { + previousPersona?: AgentPersona; + runtimes?: readonly AcpRuntimeCatalogEntry[]; + } = {}, +): UpdateManagedAgentInput | null { + if (agent.personaId !== persona.id) return null; + + const input: UpdateManagedAgentInput = { pubkey: agent.pubkey }; + let hasChanges = false; + + if (persona.displayName !== agent.name) { + input.name = persona.displayName; + hasChanges = true; + } + + if (persona.systemPrompt !== (agent.systemPrompt ?? "")) { + input.systemPrompt = persona.systemPrompt; + hasChanges = true; + } + + if ((persona.model ?? null) !== (agent.model ?? null)) { + input.model = persona.model; + hasChanges = true; + } + + if (!stringRecordEqual(persona.envVars, agent.envVars)) { + input.envVars = persona.envVars; + hasChanges = true; + } + + const runtimeChanged = + options.previousPersona !== undefined && + options.previousPersona.runtime !== persona.runtime; + const runtime = runtimeChanged + ? options.runtimes?.find((candidate) => candidate.id === persona.runtime) + : undefined; + if (runtime?.command) { + if (runtime.command !== agent.agentCommand) { + input.agentCommand = runtime.command; + hasChanges = true; + } + + if (!stringArrayEqual(runtime.defaultArgs, agent.agentArgs)) { + input.agentArgs = [...runtime.defaultArgs]; + hasChanges = true; + } + + const mcpCommand = runtime.mcpCommand ?? ""; + if (mcpCommand !== agent.mcpCommand) { + input.mcpCommand = mcpCommand; + hasChanges = true; + } + } + + return hasChanges ? input : null; +} + +function stringArrayEqual(left: readonly string[], right: readonly string[]) { + if (left.length !== right.length) return false; + + return left.every((value, index) => value === right[index]); +} + +function stringRecordEqual( + left: Record, + right: Record, +) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + + return leftKeys.every((key) => left[key] === right[key]); +} + +export function useRetainedPersona( + sourcePersona: AgentPersona | undefined, + profileIdentityKey: string, +) { + const [retainedPersona, setRetainedPersona] = React.useState<{ + key: string; + persona: AgentPersona; + } | null>(null); + + React.useEffect(() => { + if (!sourcePersona) return; + setRetainedPersona({ key: profileIdentityKey, persona: sourcePersona }); + }, [profileIdentityKey, sourcePersona]); + + return ( + sourcePersona ?? + (retainedPersona?.key === profileIdentityKey + ? retainedPersona.persona + : undefined) + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx new file mode 100644 index 000000000..edb3a14dc --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx @@ -0,0 +1,67 @@ +import type { + AcpRuntimeCatalogEntry, + AgentPersona, + CreatePersonaInput, + UpdatePersonaInput, +} from "@/shared/api/types"; +import { PersonaDeleteDialog } from "@/features/agents/ui/PersonaDeleteDialog"; +import { PersonaDialog } from "@/features/agents/ui/PersonaDialog"; +import type { PersonaDialogState } from "@/features/agents/ui/personaDialogState"; + +export function UserProfilePersonaDialogs({ + createError, + isPending, + personaDialogState, + personaToDelete, + runtimes, + runtimesLoading, + updateError, + onCloseDelete, + onCloseDialog, + onConfirmDelete, + onSubmit, +}: { + createError: Error | null; + isPending: boolean; + personaDialogState: PersonaDialogState | null; + personaToDelete: AgentPersona | null; + runtimes: AcpRuntimeCatalogEntry[]; + runtimesLoading: boolean; + updateError: Error | null; + onCloseDelete: () => void; + onCloseDialog: () => void; + onConfirmDelete: (persona: AgentPersona) => void; + onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; +}) { + return ( + <> + { + if (!open) { + onCloseDialog(); + } + }} + onSubmit={onSubmit} + open={personaDialogState !== null} + submitLabel={personaDialogState?.submitLabel ?? "Save"} + title={personaDialogState?.title ?? "Agent"} + /> + { + if (!open) { + onCloseDelete(); + } + }} + open={personaToDelete !== null} + persona={personaToDelete} + /> + + ); +} diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx index c62af561a..eea5f354b 100644 --- a/desktop/src/shared/context/ProfilePanelContext.tsx +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -1,23 +1,32 @@ import * as React from "react"; +import type { AgentPersona } from "@/shared/api/types"; + type ProfilePanelContextValue = { openProfilePanel: ((pubkey: string) => void) | null; + openPersonaProfilePanel: ((persona: AgentPersona) => void) | null; }; const ProfilePanelContext = React.createContext({ openProfilePanel: null, + openPersonaProfilePanel: null, }); export function ProfilePanelProvider({ children, onOpenProfilePanel, + onOpenPersonaProfilePanel, }: { children: React.ReactNode; onOpenProfilePanel: (pubkey: string) => void; + onOpenPersonaProfilePanel?: (persona: AgentPersona) => void; }) { const value = React.useMemo( - () => ({ openProfilePanel: onOpenProfilePanel }), - [onOpenProfilePanel], + () => ({ + openProfilePanel: onOpenProfilePanel, + openPersonaProfilePanel: onOpenPersonaProfilePanel ?? null, + }), + [onOpenPersonaProfilePanel, onOpenProfilePanel], ); return ( From 85a543851105025788f08ea82d131203bd7dd91b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 22:53:50 -0700 Subject: [PATCH 02/30] fix(desktop): enable all agent profile ingress views - Use the shared profile panel view parser for channel and Pulse URL search state so every focused profile subview survives navigation and reloads. - Replace the old memories/channels-only profileView whitelist in channel and Pulse route validation. - Expand the profile Playwright coverage to exercise agent instruction, model, settings, diagnostics/logs, channels, and memories ingress rows from a mock managed agent profile. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho (cherry picked from commit dbad79688a94e310e257272c675b6b3b0248c7f2) --- .../src/app/routes/channels.$channelId.tsx | 12 ++--- desktop/src/app/routes/pulse.tsx | 11 ++-- .../ui/useChannelPanelHistoryState.ts | 11 ++-- desktop/src/features/pulse/ui/PulseScreen.tsx | 6 +-- desktop/tests/e2e/profile.spec.ts | 50 ++++++++++++++++++- 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..5bffcbd9f 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,10 +20,6 @@ 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 validateChannelSearch( search: Record, ): ChannelRouteSearch { @@ -27,7 +27,7 @@ function validateChannelSearch( agentSession: nonEmptyString(search.agentSession), messageId: nonEmptyString(search.messageId), profile: nonEmptyString(search.profile), - profileView: profileViewValue(search.profileView), + profileView: parseProfilePanelView(search.profileView) ?? undefined, thread: nonEmptyString(search.thread), threadRootId: nonEmptyString(search.threadRootId), }; 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/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 53d236f43..adfb37652 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, @@ -32,10 +35,6 @@ const CHANNEL_SEARCH_KEYS = [ "threadRootId", ] as const; -function asProfilePanelView(value: string | null): ProfilePanelView { - return value === "memories" || value === "channels" ? value : "summary"; -} - export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -74,7 +73,7 @@ export function useChannelPanelHistoryState() { openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, - profilePanelView: asProfilePanelView(values.profileView), + profilePanelView: profilePanelViewFromSearch(values.profileView), setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelPubkey, diff --git a/desktop/src/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 1baef3b14..2bb6f9cc8 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -6,6 +6,7 @@ import { type ProfilePanelView, UserProfilePanel, } from "@/features/profile/ui/UserProfilePanel"; +import { profilePanelViewFromSearch } from "@/features/profile/ui/UserProfilePanelUtils"; 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], diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 64b216cfe..f25ef096e 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -627,7 +627,7 @@ test("updates presence from the profile menu", async ({ page }) => { ).toContainText("Offline"); }); -test("renders agent memories seeded through the Playwright mock bridge", async ({ +test("renders agent profile ingress subviews from the Playwright mock bridge", async ({ page, }) => { await installMockBridge(page, { @@ -671,6 +671,54 @@ test("renders agent memories seeded through the Playwright mock bridge", async ( await messageRow.locator("button").first().click(); await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + const openSubview = async (testId: string, title: string) => { + await page.getByTestId(testId).click(); + await expect( + page.getByRole("heading", { level: 2, name: title }), + ).toBeVisible(); + }; + const backToProfile = async () => { + await page.getByTestId("user-profile-panel-back").click(); + await expect( + page.getByRole("heading", { level: 2, name: "Profile" }), + ).toBeVisible(); + }; + + await openSubview( + "user-profile-agent-instruction-ingress", + "Agent instruction", + ); + await expect( + page.getByTestId("user-profile-agent-instruction"), + ).toContainText("Watch the channel and help when asked."); + await backToProfile(); + + await openSubview("user-profile-model-ingress", "Model"); + await expect( + page.getByRole("heading", { level: 2, name: "Model" }), + ).toBeVisible(); + await backToProfile(); + + await openSubview("user-profile-agent-settings-ingress", "Agent settings"); + await expect( + page.getByTestId(`user-profile-agent-auto-start-${agentPubkey}`), + ).toBeVisible(); + await backToProfile(); + + await openSubview("user-profile-diagnostics-ingress", "Diagnostics"); + await page.getByTestId(`user-profile-agent-logs-${agentPubkey}`).click(); + await expect( + page.getByRole("heading", { level: 2, name: "Harness log" }), + ).toBeVisible(); + await expect(page.getByTestId("managed-agent-log-content")).toBeVisible(); + await backToProfile(); + + await openSubview("user-profile-channels-ingress", "Channels"); + await expect(page.getByTestId("user-profile-channels-list")).toContainText( + "#general", + ); + await backToProfile(); + const memoriesIngress = page.getByTestId("user-profile-memories-ingress"); await expect(memoriesIngress).toContainText("Memories"); await expect(memoriesIngress).toContainText("9"); From ba47f021f8a1df24501b58374d8c446c86a328e2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:10:33 -0700 Subject: [PATCH 03/30] fix(desktop): handle model discovery setup states - Reuse managed-agent runtime defaults, provider/model env injection, and Databricks defaults when querying agent models so discovery matches normal startup behavior. - Add a configurationError field to model discovery responses for known missing provider, model, or credential setup issues instead of treating them as generic subprocess failures. - Update the profile model picker to render actionable setup messaging for configuration gaps and reserve the red failure state for unexpected discovery errors. - Show provider-backed agents as remotely managed in the model focused view rather than exposing a local model picker. - Keep E2E Tauri mocks aligned with the expanded AgentModelsResponse shape. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src-tauri/src/commands/agent_models.rs | 144 +++++++++++++++--- .../src-tauri/src/managed_agents/runtime.rs | 4 +- desktop/src-tauri/src/managed_agents/types.rs | 2 + .../src/features/agents/ui/ModelPicker.tsx | 22 +++ .../profile/ui/UserProfilePanelSections.tsx | 8 +- desktop/src/shared/api/types.ts | 1 + desktop/src/testing/e2eBridge.ts | 1 + 7 files changed, 159 insertions(+), 23 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index a09bb4f51..0d0187e97 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -6,12 +6,12 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, - known_acp_runtime, load_managed_agents, load_personas, managed_agent_avatar_url, - missing_command_message, normalize_agent_args, resolve_command, - resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, - try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, - UpdateManagedAgentResponse, + build_databricks_defaults, build_managed_agent_summary, default_agent_workdir, + find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas, + managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, + resolve_effective_prompt_model_provider, runtime_metadata_env_vars, save_managed_agents, + sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -27,7 +27,16 @@ pub async fn get_agent_models( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let (resolved_acp, agent_command, agent_args, persisted_model, merged_env) = { + let ( + resolved_acp, + agent_command, + agent_args, + persisted_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + merged_env, + ) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -75,14 +84,51 @@ pub async fn get_agent_models( // Resolve the effective model from the linked persona so the ModelPicker // dropdown shows the current persona model as selected. - let (_prompt, effective_model, _provider) = resolve_effective_prompt_model_provider( - record.persona_id.as_deref(), - &personas, - record.system_prompt.clone(), - record.model.clone(), - ); - - (resolved, resolved_agent, args, effective_model, env) + let (_prompt, effective_model, effective_provider) = + resolve_effective_prompt_model_provider( + record.persona_id.as_deref(), + &personas, + record.system_prompt.clone(), + record.model.clone(), + ); + let runtime = known_acp_runtime(&record.agent_command); + let runtime_default_env: Vec<(String, String)> = runtime + .map(|meta| { + meta.default_env + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + }) + .unwrap_or_default(); + let runtime_metadata_env: Vec<(String, String)> = runtime + .map(|meta| { + runtime_metadata_env_vars( + meta.model_env_var, + meta.provider_env_var, + meta.provider_locked, + effective_model.as_deref(), + effective_provider.as_deref(), + ) + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + }) + .unwrap_or_default(); + let databricks_defaults: Vec<(String, String)> = build_databricks_defaults() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + + ( + resolved, + resolved_agent, + args, + effective_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + env, + ) }; // store lock released — subprocess runs without holding the lock // Clone the env map for redaction below — `merged_env` is moved @@ -105,13 +151,17 @@ pub async fn get_agent_models( .arg("--json") .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); - if let Some(meta) = known_acp_runtime(&agent_command) { - for (key, value) in meta.default_env { - if std::env::var(key).is_err() { - cmd.env(key, value); - } + for (key, value) in &runtime_default_env { + if std::env::var(key).is_err() { + cmd.env(key, value); } } + for (key, value) in &runtime_metadata_env { + cmd.env(key, value); + } + for (key, value) in &databricks_defaults { + cmd.env(key, value); + } // User env layering — written LAST so it overrides any Buzz-set env above. for (k, v) in &merged_env { cmd.env(k, v); @@ -132,6 +182,12 @@ pub async fn get_agent_models( // a failing child process echoed back. let stderr_redacted = crate::managed_agents::redact_env_values_in(stderr.as_ref(), &env_for_redaction); + if let Some(configuration_error) = model_configuration_error(&stderr_redacted) { + return Ok(unavailable_agent_models( + persisted_model, + configuration_error, + )); + } return Err(format!( "buzz-acp models failed (exit {}): {stderr_redacted}", output.status.code().unwrap_or(-1) @@ -413,5 +469,53 @@ fn normalize_agent_models( agent_default_model, selected_model: persisted_model, supports_switching, + configuration_error: None, + } +} + +fn unavailable_agent_models( + persisted_model: Option, + configuration_error: String, +) -> AgentModelsResponse { + AgentModelsResponse { + agent_name: "unknown".to_string(), + agent_version: "unknown".to_string(), + models: Vec::new(), + agent_default_model: None, + selected_model: persisted_model, + supports_switching: false, + configuration_error: Some(configuration_error), + } +} + +fn model_configuration_error(stderr: &str) -> Option { + let normalized = stderr.to_ascii_lowercase(); + + if normalized.contains("buzz_agent_provider required") { + return Some( + "This agent does not have an LLM provider configured. Set a provider and model on the persona or agent, then retry." + .to_string(), + ); } + + if normalized.contains("anthropic_model required") + || normalized.contains("openai_compat_model required") + || normalized.contains("databricks_model required") + { + return Some( + "This agent does not have an LLM model configured. Set a model on the persona or agent, then retry." + .to_string(), + ); + } + + if normalized.contains("anthropic_api_key required") + || normalized.contains("openai_compat_api_key required") + { + return Some( + "This agent is missing credentials for its configured LLM provider. Add the provider credentials, then retry." + .to_string(), + ); + } + + None } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3bc6ddd24..4d511e79d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1845,7 +1845,7 @@ fn child_rust_log_filter() -> String { /// Databricks host/model baked in at compile time for internal builds. Empty /// in OSS builds, where the `BUZZ_BUILD_DATABRICKS_*` env is unset. -fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { +pub(crate) fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { let mut defaults = Vec::new(); if let Some(host) = option_env!("BUZZ_DESKTOP_BUILD_DATABRICKS_HOST") { if !host.is_empty() { @@ -1977,7 +1977,7 @@ pub fn stop_managed_agent_process( /// switching need the initial bootstrap value. Provider injection is skipped /// when `provider_locked` is true (e.g. Claude runtimes that only work with /// Anthropic). -fn runtime_metadata_env_vars<'a>( +pub(crate) fn runtime_metadata_env_vars<'a>( model_env_var: Option<&'a str>, provider_env_var: Option<&'a str>, provider_locked: bool, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 0e777ec0d..1fa5c54ea 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -535,6 +535,8 @@ pub struct AgentModelsResponse { pub selected_model: Option, /// Whether this agent supports model switching. pub supports_switching: bool, + /// Human-readable setup issue that prevents model discovery. + pub configuration_error: Option, } /// A single model available from an agent. diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 12950201e..fd5f236b8 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -109,6 +109,9 @@ export function ModelPicker({ ) : error ? (

Failed to load models.

+

+ {error} +

+
) : !modelsData.supportsSwitching ? (
{agent.model ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 421b39b8a..2291dfd45 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -835,6 +835,8 @@ export function ModelFocusedView({ modelLabel: string; onModelChanged: () => void; }) { + const canPickModel = managedAgent?.backend.type === "local"; + return (
@@ -849,8 +851,12 @@ export function ModelFocusedView({ {modelLabel} - {managedAgent ? ( + {canPickModel && managedAgent ? ( + ) : managedAgent ? ( + + managed remotely + ) : null}
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index ef59385aa..1c5969e3d 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -470,6 +470,7 @@ export type AgentModelsResponse = { agentDefaultModel: string | null; selectedModel: string | null; supportsSwitching: boolean; + configurationError: string | null; }; export type AgentModelInfo = { id: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ba2148d25..933ce1813 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6724,6 +6724,7 @@ export function maybeInstallE2eTauriMocks() { agentDefaultModel: null, selectedModel: null, supportsSwitching: false, + configurationError: null, }; case "update_managed_agent": return handleUpdateManagedAgent( From 93f3f429772aec2d198c87b456909afc3b204d6b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:36:32 -0700 Subject: [PATCH 04/30] feat(profile): surface agent info fields in summary - Replace the Agent info summary ingress with the existing ProfileFieldGroup card for Public key and Owned by. - Preserve the shared muted background and adjacent field styling by reusing the focused profile field container. - Remove the now-unused Agent info summary click handler and Info icon import while leaving the focused info view available for other metadata. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/UserProfilePanel.tsx | 1 - .../profile/ui/UserProfilePanelSections.tsx | 20 +++++++------------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 34f5a83d2..4d579fafb 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -847,7 +847,6 @@ export function UserProfilePanel({ diagnosticsFields={diagnosticsFields} diagnosticsSummary={diagnosticsSummary} modelLabel={modelLabel} - onOpenAgentInfo={() => setView("info")} onOpenAgentSettings={() => setView("settings")} onOpenChannels={() => setView("channels")} onOpenDiagnostics={() => setView("diagnostics")} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 2291dfd45..97e7d0a42 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -10,7 +10,6 @@ import { Cpu, FileText, Hash, - Info, MessageSquare, Pencil, Play, @@ -90,7 +89,6 @@ export type ProfileSummaryViewProps = { diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; modelLabel: string; - onOpenAgentInfo: () => void; onOpenAgentSettings: () => void; onOpenChannels: () => void; onOpenDiagnostics: () => void; @@ -140,7 +138,6 @@ export function ProfileSummaryView({ diagnosticsFields, diagnosticsSummary, modelLabel, - onOpenAgentInfo, onOpenAgentSettings, onOpenChannels, onOpenDiagnostics, @@ -171,7 +168,10 @@ export function ProfileSummaryView({ (agentSettingsFields.length > 0 || managedAgent?.backend.type === "local"); const showDiagnosticsIngress = diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; - const showAgentInfoIngress = agentInfoFields.length > 0; + const topLevelAgentInfoFields = agentInfoFields.filter( + (field) => field.label === "Public key" || field.label === "Owned by", + ); + const showTopLevelAgentInfo = topLevelAgentInfoFields.length > 0; const personaActionKey = persona?.id; return ( @@ -238,7 +238,7 @@ export function ProfileSummaryView({ showChannelsIngress || showAgentSettingsIngress || showDiagnosticsIngress || - showAgentInfoIngress ? ( + showTopLevelAgentInfo ? (
{showInstructionIngress ? ( ) : null} - {showAgentInfoIngress ? ( - + {showTopLevelAgentInfo ? ( + ) : null}
) : null} From adbb2788ac81ae7e551c9f213d8208d3998156ca Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:42:36 -0700 Subject: [PATCH 05/30] feat(profile): combine agent configuration ingress - Replace separate profile sidebar rows for agent instructions, model, and settings with a single Agent configuration ingress. - Add a combined configuration focused view that reuses the existing instruction, model picker, and settings sections under one destination. - Canonicalize legacy profileView values for instructions, model, and settings to configuration so existing deep links continue to resolve. - Update profile panel utility tests to cover the new canonical view and legacy aliases. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/UserProfilePanel.tsx | 31 +--- .../profile/ui/UserProfilePanelSections.tsx | 158 ++++++++++++++---- .../profile/ui/UserProfilePanelUtils.test.mjs | 10 +- .../profile/ui/UserProfilePanelUtils.ts | 27 +-- 4 files changed, 154 insertions(+), 72 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 4d579fafb..ad8f98f0b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -54,13 +54,11 @@ import { useUserProfileQuery, } from "@/features/profile/hooks"; import { + AgentConfigurationFocusedView, AgentInfoFocusedView, - AgentInstructionFocusedView, - AgentSettingsFocusedView, ChannelsFocusedView, DiagnosticsFocusedView, MemoryFocusedView, - ModelFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; @@ -847,12 +845,10 @@ export function UserProfilePanel({ diagnosticsFields={diagnosticsFields} diagnosticsSummary={diagnosticsSummary} modelLabel={modelLabel} - onOpenAgentSettings={() => setView("settings")} + onOpenAgentConfiguration={() => setView("configuration")} onOpenChannels={() => setView("channels")} onOpenDiagnostics={() => setView("diagnostics")} - onOpenInstruction={() => setView("instructions")} onOpenMemories={() => setView("memories")} - onOpenModel={() => setView("model")} onOpenDm={onOpenDm} persona={resolvedPersona} presenceStatus={presenceStatus} @@ -871,30 +867,19 @@ export function UserProfilePanel({ /> ) : null} - {view === "instructions" ? ( - - ) : null} - {view === "info" ? ( ) : null} - {view === "model" ? ( - void managedAgentsQuery.refetch()} - /> - ) : null} - - {view === "settings" ? ( - void managedAgentsQuery.refetch()} onToggleAutoStart={handleToggleAgentAutoStart} /> ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 97e7d0a42..0d6668700 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -89,12 +89,10 @@ export type ProfileSummaryViewProps = { diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; modelLabel: string; - onOpenAgentSettings: () => void; + onOpenAgentConfiguration: () => void; onOpenChannels: () => void; onOpenDiagnostics: () => void; - onOpenInstruction: () => void; onOpenMemories: () => void; - onOpenModel: () => void; onOpenDm?: (pubkeys: string[]) => void; persona?: AgentPersona; presenceStatus: "online" | "away" | "offline" | undefined; @@ -138,12 +136,10 @@ export function ProfileSummaryView({ diagnosticsFields, diagnosticsSummary, modelLabel, - onOpenAgentSettings, + onOpenAgentConfiguration, onOpenChannels, onOpenDiagnostics, - onOpenInstruction, onOpenMemories, - onOpenModel, onOpenDm, persona, presenceStatus, @@ -166,8 +162,15 @@ export function ProfileSummaryView({ const showAgentSettingsIngress = isOwner === true && (agentSettingsFields.length > 0 || managedAgent?.backend.type === "local"); + const showAgentConfigurationIngress = + showInstructionIngress || showModelIngress || showAgentSettingsIngress; const showDiagnosticsIngress = diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; + const agentConfigurationTrailing = showModelIngress + ? modelLabel + : handleEditPersona + ? "Edit" + : "View"; const topLevelAgentInfoFields = agentInfoFields.filter( (field) => field.label === "Public key" || field.label === "Owned by", ); @@ -232,30 +235,19 @@ export function ProfileSummaryView({
) : null} - {showInstructionIngress || - showModelIngress || + {showAgentConfigurationIngress || showMemoriesIngress || showChannelsIngress || - showAgentSettingsIngress || showDiagnosticsIngress || showTopLevelAgentInfo ? (
- {showInstructionIngress ? ( + {showAgentConfigurationIngress ? ( - ) : null} - {showModelIngress ? ( - ) : null} {showMemoriesIngress ? ( @@ -288,15 +280,6 @@ export function ProfileSummaryView({ } /> ) : null} - {showAgentSettingsIngress ? ( - - ) : null} {showDiagnosticsIngress ? ( void; @@ -832,7 +817,7 @@ export function ModelFocusedView({ const canPickModel = managedAgent?.backend.type === "local"; return ( -
+
@@ -858,11 +843,13 @@ export function ModelFocusedView({ } export function AgentSettingsFocusedView({ + className, fields, isActionPending, managedAgent, onToggleAutoStart, }: { + className?: string; fields: ProfileField[]; isActionPending: boolean; managedAgent: ManagedAgent | undefined; @@ -876,7 +863,7 @@ export function AgentSettingsFocusedView({ } return ( -
+
{canToggleAutoStart && managedAgent ? ( void; + onModelChanged: () => void; + onToggleAutoStart: () => void; +}) { + const trimmedInstruction = instruction?.trim() ?? ""; + const showInstructions = + trimmedInstruction.length > 0 || onEditInstruction !== undefined; + const showModel = managedAgent !== undefined || modelLabel.trim().length > 0; + const showSettings = + fields.length > 0 || managedAgent?.backend.type === "local"; + + if (!showInstructions && !showModel && !showSettings) { + return null; + } + + return ( +
+ {showInstructions ? ( + + + + ) : null} + + {showModel ? ( + + + + ) : null} + + {showSettings ? ( + + + + ) : null} +
+ ); +} + +function AgentConfigurationSection({ + children, + description, + title, +}: { + children: React.ReactNode; + description: string; + title: string; +}) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + export function DiagnosticsFocusedView({ canOpenAgentLogs, canViewActivity, @@ -945,16 +1029,18 @@ export function DiagnosticsFocusedView({ } export function AgentInstructionFocusedView({ + className, instruction, onEdit, }: { + className?: string; instruction: string | null; onEdit?: () => void; }) { const trimmedInstruction = instruction?.trim() ?? ""; return ( -
+
{trimmedInstruction ? (
diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 287e944d5..56859e451 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -155,10 +155,8 @@ test("parseProfilePanelView accepts all profile panel subviews", () => { for (const view of [ "summary", "info", - "settings", + "configuration", "diagnostics", - "model", - "instructions", "memories", "channels", "logs", @@ -167,6 +165,12 @@ test("parseProfilePanelView accepts all profile panel subviews", () => { } }); +test("parseProfilePanelView maps legacy agent config subviews to configuration", () => { + for (const view of ["instructions", "model", "settings"]) { + assert.equal(parseProfilePanelView(view), "configuration"); + } +}); + test("profilePanelViewFromSearch falls back to summary for invalid values", () => { assert.equal(parseProfilePanelView("missing"), null); assert.equal(profilePanelViewFromSearch("missing"), "summary"); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 026c2dbc4..34c8482ff 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -18,10 +18,8 @@ export type ProfileChannelLink = { export type ProfilePanelView = | "summary" | "info" - | "settings" + | "configuration" | "diagnostics" - | "model" - | "instructions" | "memories" | "channels" | "logs"; @@ -29,10 +27,8 @@ export type ProfilePanelView = export const PROFILE_PANEL_VIEW_TITLES: Record = { summary: "Profile", info: "Agent info", - settings: "Agent settings", + configuration: "Agent configuration", diagnostics: "Diagnostics", - model: "Model", - instructions: "Agent instruction", memories: "Memories", channels: "Channels", logs: "Harness log", @@ -42,11 +38,22 @@ const PROFILE_PANEL_VIEWS = new Set( Object.keys(PROFILE_PANEL_VIEW_TITLES) as ProfilePanelView[], ); +const LEGACY_PROFILE_PANEL_VIEW_ALIASES: Record = { + instructions: "configuration", + model: "configuration", + settings: "configuration", +}; + export function parseProfilePanelView(value: unknown): ProfilePanelView | null { - return typeof value === "string" && - PROFILE_PANEL_VIEWS.has(value as ProfilePanelView) - ? (value as ProfilePanelView) - : null; + if (typeof value !== "string") { + return null; + } + + if (PROFILE_PANEL_VIEWS.has(value as ProfilePanelView)) { + return value as ProfilePanelView; + } + + return LEGACY_PROFILE_PANEL_VIEW_ALIASES[value] ?? null; } export function profilePanelViewFromSearch(value: unknown): ProfilePanelView { From 4227ee61b791bc70dbc09507c62c763bff8ff0c9 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:59:51 -0700 Subject: [PATCH 06/30] feat(profile): refine agent diagnostics log layout - Move the agent activity log ingress to the top-level profile sidebar actions so Diagnostics focuses on operational status and logs. - Render Diagnostics status with the existing agent status badge instead of plain summary text. - Embed the Harness log directly in Diagnostics and use flex sizing so the terminal fills the available height while the log body scrolls internally. - Collapse Harness log chrome into the terminal frame with a compact dark copy action and footer-mounted log filename. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/agents/ui/CopyButton.tsx | 17 ++- .../agents/ui/ManagedAgentLogPanel.tsx | 111 ++++++++++++++---- .../features/profile/ui/UserProfilePanel.tsx | 35 ++++-- .../profile/ui/UserProfilePanelSections.tsx | 77 ++++++------ 4 files changed, 164 insertions(+), 76 deletions(-) diff --git a/desktop/src/features/agents/ui/CopyButton.tsx b/desktop/src/features/agents/ui/CopyButton.tsx index a8d0b621f..4833fcae3 100644 --- a/desktop/src/features/agents/ui/CopyButton.tsx +++ b/desktop/src/features/agents/ui/CopyButton.tsx @@ -1,24 +1,31 @@ import { Copy } from "lucide-react"; import { toast } from "sonner"; -import { Button } from "@/shared/ui/button"; +import { Button, type ButtonProps } from "@/shared/ui/button"; export function CopyButton({ - value, + className, label, + size = "sm", + value, + variant = "outline", }: { - value: string; + className?: string; label?: string; + size?: ButtonProps["size"]; + value: string; + variant?: ButtonProps["variant"]; }) { return (
); + const isDiagnosticsLikeView = view === "diagnostics" || view === "logs"; const profileBody = (
setView("configuration")} onOpenChannels={() => setView("channels")} onOpenDiagnostics={() => setView("diagnostics")} @@ -887,12 +892,15 @@ export function UserProfilePanel({ {view === "diagnostics" ? ( setView("logs")} - pubkey={effectivePubkey} /> ) : null} @@ -908,16 +916,17 @@ export function UserProfilePanel({ ) : null} {view === "logs" ? ( - ) : null}
diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 0d6668700..3fbe5bfc9 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -8,7 +8,6 @@ import { ChevronRight, ChevronUp, Cpu, - FileText, Hash, MessageSquare, Pencil, @@ -25,6 +24,7 @@ import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { ModelPicker } from "@/features/agents/ui/ModelPicker"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { getPresenceLabel } from "@/features/presence/lib/presence"; @@ -89,6 +89,7 @@ export type ProfileSummaryViewProps = { diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; modelLabel: string; + onOpenActivity: () => void; onOpenAgentConfiguration: () => void; onOpenChannels: () => void; onOpenDiagnostics: () => void; @@ -136,6 +137,7 @@ export function ProfileSummaryView({ diagnosticsFields, diagnosticsSummary, modelLabel, + onOpenActivity, onOpenAgentConfiguration, onOpenChannels, onOpenDiagnostics, @@ -165,12 +167,18 @@ export function ProfileSummaryView({ const showAgentConfigurationIngress = showInstructionIngress || showModelIngress || showAgentSettingsIngress; const showDiagnosticsIngress = - diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; + diagnosticsFields.length > 0 || canOpenAgentLogs; + const showActivityIngress = canViewActivity; const agentConfigurationTrailing = showModelIngress ? modelLabel : handleEditPersona ? "Edit" : "View"; + const diagnosticsStatusField = diagnosticsFields.find( + (field) => field.label === "Status", + ); + const diagnosticsTrailing = + diagnosticsStatusField?.displayNode ?? diagnosticsSummary ?? "View"; const topLevelAgentInfoFields = agentInfoFields.filter( (field) => field.label === "Public key" || field.label === "Owned by", ); @@ -239,6 +247,7 @@ export function ProfileSummaryView({ showMemoriesIngress || showChannelsIngress || showDiagnosticsIngress || + showActivityIngress || showTopLevelAgentInfo ? (
{showAgentConfigurationIngress ? ( @@ -286,7 +295,16 @@ export function ProfileSummaryView({ label="Diagnostics" onClick={onOpenDiagnostics} testId="user-profile-diagnostics-ingress" - trailing={diagnosticsSummary ?? "View"} + trailing={diagnosticsTrailing} + /> + ) : null} + {showActivityIngress ? ( + ) : null} {showTopLevelAgentInfo ? ( @@ -667,8 +685,10 @@ function ProfileIngressRow({ label: string; onClick: () => void; testId: string; - trailing?: string; + trailing?: React.ReactNode; }) { + const trailingTitle = typeof trailing === "string" ? trailing : undefined; + return ( + ); +} + function ProfileWorkingBadge({ channelId, name, @@ -834,183 +862,6 @@ export function AgentInfoFocusedView({ ); } -export function ModelFocusedView({ - className, - managedAgent, - modelLabel, - onModelChanged, -}: { - className?: string; - managedAgent: ManagedAgent | undefined; - modelLabel: string; - onModelChanged: () => void; -}) { - const canPickModel = managedAgent?.backend.type === "local"; - - return ( -
-
- - - - - - Model - - - {modelLabel} - - - {canPickModel && managedAgent ? ( - - ) : managedAgent ? ( - - managed remotely - - ) : null} -
-
- ); -} - -export function AgentSettingsFocusedView({ - className, - fields, - isActionPending, - managedAgent, - onToggleAutoStart, -}: { - className?: string; - 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 ( -
- {canToggleAutoStart && managedAgent ? ( - - ) : null} - {fields.length > 0 ? : null} -
- ); -} - -export function AgentConfigurationFocusedView({ - fields, - instruction, - isActionPending, - managedAgent, - modelLabel, - onEditInstruction, - onModelChanged, - onToggleAutoStart, -}: { - fields: ProfileField[]; - instruction: string | null; - isActionPending: boolean; - managedAgent: ManagedAgent | undefined; - modelLabel: string; - onEditInstruction?: () => void; - onModelChanged: () => void; - onToggleAutoStart: () => void; -}) { - const trimmedInstruction = instruction?.trim() ?? ""; - const showInstructions = - trimmedInstruction.length > 0 || onEditInstruction !== undefined; - const showModel = managedAgent !== undefined || modelLabel.trim().length > 0; - const showSettings = - fields.length > 0 || managedAgent?.backend.type === "local"; - - if (!showInstructions && !showModel && !showSettings) { - return null; - } - - return ( -
- {showInstructions ? ( - - - - ) : null} - - {showModel ? ( - - - - ) : null} - - {showSettings ? ( - - - - ) : null} -
- ); -} - -function AgentConfigurationSection({ - children, - description, - title, -}: { - children: React.ReactNode; - description: string; - title: string; -}) { - return ( -
-
-

{title}

-

{description}

-
- {children} -
- ); -} - export function DiagnosticsFocusedView({ canOpenAgentLogs, fields, @@ -1068,50 +919,3 @@ export function DiagnosticsFocusedView({
); } - -export function AgentInstructionFocusedView({ - className, - instruction, - onEdit, -}: { - className?: string; - instruction: string | null; - onEdit?: () => void; -}) { - const trimmedInstruction = instruction?.trim() ?? ""; - - return ( -
-
- {trimmedInstruction ? ( -
- -
- ) : ( -

- No instruction set. -

- )} -
- {onEdit ? ( - - ) : null} -
- ); -} diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 34c8482ff..b11a5d5c3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -27,7 +27,7 @@ export type ProfilePanelView = export const PROFILE_PANEL_VIEW_TITLES: Record = { summary: "Profile", info: "Agent info", - configuration: "Agent configuration", + configuration: "Advanced", diagnostics: "Diagnostics", memories: "Memories", channels: "Channels", From dbf13ed2edfe8cf41e5c0d6a108b6d4ea0f15dad Mon Sep 17 00:00:00 2001 From: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f Date: Wed, 24 Jun 2026 01:06:24 -0700 Subject: [PATCH 09/30] feat(profile): merge owner and respond-to into one field When a managed agent responds only to its owner, collapse the separate Owned by and Respond to rows into a single Owned by & responds to field. Surface the agent info summary fields regardless of viewer ownership. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../profile/ui/UserProfilePanelFields.tsx | 60 +++++++++++-------- .../profile/ui/UserProfilePanelSections.tsx | 8 ++- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 77e9840c1..33cbeb18a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -50,6 +50,7 @@ export type ProfileField = { const AGENT_INFO_LABELS = new Set([ "Public key", "Owned by", + "Owned by & responds to", "NIP-05", "Agent type", "Capabilities", @@ -251,6 +252,15 @@ export function buildOwnerFields({ relayAgent: RelayAgent | undefined; }): ProfileField[] { const fields: ProfileField[] = []; + const combinesOwnerRespondTo = + managedAgent?.respondTo === "owner-only" && Boolean(ownerDisplayName); + const respondToOwner = + managedAgent?.respondTo === "owner-only" && ownerDisplayName; + const respondToDisplayValue = managedAgent + ? respondToOwner + ? ownerDisplayName + : managedAgent.respondTo.replace(/-/g, " ") + : null; const ownerClickable = Boolean(onOpenProfile && ownerPubkey); @@ -283,12 +293,20 @@ export function buildOwnerFields({ ) : undefined, icon: UserRound, - label: "Owned by", + label: combinesOwnerRespondTo ? "Owned by & responds to" : "Owned by", testId: "user-profile-owned-by", }); } if (!includeOperationalFields) { + if (managedAgent && !respondToOwner && respondToDisplayValue) { + fields.push({ + displayValue: respondToDisplayValue, + icon: Ear, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } return fields; } @@ -386,36 +404,20 @@ export function buildOwnerFields({ } if (managedAgent) { - const respondToOwner = - managedAgent.respondTo === "owner-only" && ownerDisplayName; - const respondToDisplayValue = respondToOwner - ? ownerDisplayName - : managedAgent.respondTo.replace(/-/g, " "); - fields.push({ displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No", icon: Server, label: "Start on launch", testId: "user-profile-start-on-launch", }); - fields.push({ - displayNode: respondToOwner ? ( - - - {respondToDisplayValue} - - ) : undefined, - displayValue: respondToDisplayValue, - icon: Ear, - label: "Respond to", - testId: "user-profile-respond-to", - }); + if (!respondToOwner && respondToDisplayValue) { + fields.push({ + displayValue: respondToDisplayValue, + icon: Ear, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } } if (managedAgent?.lastError) { @@ -434,14 +436,19 @@ export function buildOwnerFields({ function orderProfileFields(fields: ProfileField[]) { const publicKeyLabel = "Public key"; const ownedByLabel = "Owned by"; + const ownedByRespondsToLabel = "Owned by & responds to"; const statusLabel = "Status"; return [ ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === ownedByLabel), + ...fields.filter( + (field) => + field.label === ownedByLabel || field.label === ownedByRespondsToLabel, + ), ...fields.filter( (field) => field.label !== publicKeyLabel && field.label !== ownedByLabel && + field.label !== ownedByRespondsToLabel && field.copyValue, ), ...fields.filter((field) => field.label === statusLabel), @@ -449,6 +456,7 @@ function orderProfileFields(fields: ProfileField[]) { if ( field.label === publicKeyLabel || field.label === ownedByLabel || + field.label === ownedByRespondsToLabel || field.label === statusLabel ) { return false; diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index cf918c005..3cdb21ba6 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -176,8 +176,7 @@ export function ProfileSummaryView({ (showInstructionIngress || showModelIngress || runtimeConfigurationFields.length > 0); - const showAgentSettingsRows = - isOwner === true && summaryAgentDetailFields.length > 0; + const showAgentSettingsRows = summaryAgentDetailFields.length > 0; const showAgentConfigurationRows = showAgentSettingsRows; const showDiagnosticsIngress = diagnosticsFields.length > 0 || canOpenAgentLogs; @@ -197,7 +196,10 @@ export function ProfileSummaryView({ (diagnosticsStatusField?.displayNode ?? diagnosticsSummary ?? "View") ); const topLevelAgentInfoFields = agentInfoFields.filter( - (field) => field.label === "Public key" || field.label === "Owned by", + (field) => + field.label === "Public key" || + field.label === "Owned by" || + field.label === "Owned by & responds to", ); const showTopLevelAgentInfo = topLevelAgentInfoFields.length > 0; const personaActionKey = persona?.id; From 1bba0b3ac2a0d3ea85b5a57bf3717204b83da5ff Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 21:13:23 -0700 Subject: [PATCH 10/30] feat(profile): move agent actions into settings menu - Replace the inline profile agent action rows with a header settings menu in UserProfileAgentActions. - Wire the cog into UserProfilePanel next to the close button and scope it to the summary view so interior views keep focused headers. - Extract the profile panel frame into UserProfilePanelFrame to keep UserProfilePanel under the desktop file-size guard. - Trim obsolete action props and rendering from ProfileSummaryView after moving duplicate, export, autostart, and delete into the menu. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../profile/ui/UserProfileAgentActions.tsx | 190 +++++++++--------- .../features/profile/ui/UserProfilePanel.tsx | 145 +++++-------- .../profile/ui/UserProfilePanelFrame.tsx | 132 ++++++++++++ .../profile/ui/UserProfilePanelSections.tsx | 41 +--- 4 files changed, 269 insertions(+), 239 deletions(-) create mode 100644 desktop/src/features/profile/ui/UserProfilePanelFrame.tsx diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx index c32ca9579..7f3a62a6e 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -1,11 +1,17 @@ -import type { LucideIcon } from "lucide-react"; -import { CopyPlus, Download, Power, Trash2 } from "lucide-react"; +import { CopyPlus, Download, Power, Settings, Trash2 } from "lucide-react"; import type { ManagedAgent } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { Switch } from "@/shared/ui/switch"; -export function UserProfileAgentActions({ +export function UserProfileAgentSettingsMenu({ isPending, managedAgent, onDelete, @@ -29,105 +35,91 @@ export function UserProfileAgentActions({ managedAgent.backend.type === "local" && onToggleAutoStart !== undefined; const autoStartSwitchId = `user-profile-agent-auto-start-${actionKey}`; + const hasPrimaryActions = Boolean(onDuplicatePersona || onExportPersona); + const hasActions = + canToggleAutoStart || hasPrimaryActions || Boolean(onDelete); + + if (!hasActions) { + return null; + } return ( -
- {canToggleAutoStart ? ( -
- + + + + + event.preventDefault()} + > + {canToggleAutoStart ? ( + { + event.preventDefault(); + onToggleAutoStart(); + }} + > - - - + Duplicate + + ) : null} + {onExportPersona ? ( + -
- ) : null} - {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 ( - + onClick={onExportPersona} + > + + Export + + ) : null} + {onDelete && (canToggleAutoStart || hasPrimaryActions) ? ( + + ) : null} + {onDelete ? ( + + + Delete agent + + ) : null} + + ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index fcb9844b7..f22933d13 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -60,6 +60,7 @@ import { ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; +import { UserProfileAgentSettingsMenu } from "@/features/profile/ui/UserProfileAgentActions"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit"; @@ -79,9 +80,7 @@ import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; -import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth"; import { - AuxiliaryPanelHeader, AuxiliaryPanelHeaderGroup, AuxiliaryPanelTitle, auxiliaryPanelContentPaddingClass, @@ -96,12 +95,7 @@ import type { 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 { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame"; export type { ProfilePanelView }; @@ -731,6 +725,30 @@ export function UserProfilePanel({ const canEditPersona = canManagePersona && resolvedPersona?.isBuiltIn !== true; const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; + const agentSettingsMenu = + viewerIsOwner && managedAgent ? ( + + ) : canInstantiateAgent ? ( + + ) : null; const { agentInfoFields, agentSettingsFields, @@ -784,6 +802,7 @@ export function UserProfilePanel({ viewerIsOwner={viewerIsOwner} /> ) : null} + {view === "summary" ? agentSettingsMenu : null} - )} - - {!isOverlay ? ( -
) : null} - - {isOwner === true && managedAgent ? ( - - ) : null} - {canInstantiateAgent ? ( - - ) : null} ); } From e550c1f7b142a901062e8f1812247f5073efda5c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 21:23:12 -0700 Subject: [PATCH 11/30] fix(profile): show avatar for fallback owner row - Use the current user's profile avatar when an agent owner row is rendered from the local "you" ownership fallback. - Reuse a shared owner row content node so both clickable owner links and copyable fallback rows render the owner avatar beside the label. - Preserve the existing copy behavior for fallback owner rows while restoring the avatar treatment added for NIP-OA owners. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/UserProfilePanel.tsx | 7 ++--- .../profile/ui/UserProfilePanelFields.tsx | 27 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f22933d13..1ecf8b380 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -714,6 +714,9 @@ export function UserProfilePanel({ ? `${ownerHandle} (you)` : ownerHandle : null; + const ownerAvatarProfile = ownerPubkey + ? ownerProfileQuery.data + : currentProfileQuery.data; const memoryCount = memoryQuery.data && (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length; @@ -760,7 +763,7 @@ export function UserProfilePanel({ isOwner, managedAgent, onOpenProfile, - ownerAvatarUrl: ownerProfileQuery.data?.avatarUrl ?? null, + ownerAvatarUrl: ownerAvatarProfile?.avatarUrl ?? null, ownerDisplayName, ownerHandle, ownerPubkey, @@ -771,7 +774,6 @@ export function UserProfilePanel({ pubkey: effectivePubkey, relayAgent, }); - const headerLeftContent = ( {view !== "summary" ? ( @@ -792,7 +794,6 @@ export function UserProfilePanel({ ); - const headerActions = (
{view === "memories" && viewerIsOwner && effectivePubkey ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 33cbeb18a..3b442a1db 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -263,6 +263,18 @@ export function buildOwnerFields({ : null; const ownerClickable = Boolean(onOpenProfile && ownerPubkey); + const ownerContent = ( + <> + + {ownerDisplayName} + + ); if (ownerDisplayName) { fields.push({ @@ -282,16 +294,13 @@ export function buildOwnerFields({ title={ownerDisplayName} type="button" > - - {ownerDisplayName} + {ownerContent} - ) : undefined, + ) : ( + + {ownerContent} + + ), icon: UserRound, label: combinesOwnerRespondTo ? "Owned by & responds to" : "Owned by", testId: "user-profile-owned-by", From feee8365e5b66a70cabd4f23425ee103bcee4740 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 21:26:57 -0700 Subject: [PATCH 12/30] fix(profile): show agent details before ingresses - Move the top-level agent details card above the Memories, Channels, Diagnostics, and Activity ingress rows in the profile summary. - Keep the existing public key, ownership, agent detail, and Advanced row rendering intact while changing only the visual order. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../profile/ui/UserProfilePanelSections.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 1157fcafa..96cbd05d2 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -252,6 +252,19 @@ export function ProfileSummaryView({ showActivityIngress || showTopLevelAgentInfo ? (
+ {showAgentConfigurationRows || + showRuntimeConfigurationIngress || + showTopLevelAgentInfo ? ( +
+ {showTopLevelAgentInfo ? ( + + ) : null} + + {showRuntimeConfigurationIngress ? ( + + ) : null} +
+ ) : null} {showMemoriesIngress ? ( ) : null} - {showAgentConfigurationRows || - showRuntimeConfigurationIngress || - showTopLevelAgentInfo ? ( -
- {showTopLevelAgentInfo ? ( - - ) : null} - - {showRuntimeConfigurationIngress ? ( - - ) : null} -
- ) : null}
) : null}
From 06d495347b2f2182e680d014e81bdcdbee3e9bd2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 21:49:46 -0700 Subject: [PATCH 13/30] feat(profile): add tabbed agent profile with drag-scroll tab bar Replace stacked ingress rows with inline Info, Runtime, Channels, and Memories tabs below the profile action buttons so agent details stay on one screen. - UserProfilePanelTabs: secondary/ghost Button tabs, momentum drag-scroll, Info/Runtime tab content, shared ProfileIngressRow - UserProfilePanelSections: wire tabs into ProfileSummaryView - Info tab: instructions first, identity fields, activity log ingress - Runtime tab: model, runtime config, respond-to, diagnostics ingress - Split combined owned-by/respond-to into separate fields - Rename Advanced focused view title to Runtime - profile.spec.ts: update E2E to click tabs instead of ingress rows Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/UserProfilePanel.tsx | 10 +- .../ui/UserProfilePanelAgentDetails.tsx | 17 +- .../profile/ui/UserProfilePanelFields.tsx | 28 +- .../profile/ui/UserProfilePanelSections.tsx | 303 +++++++-------- .../profile/ui/UserProfilePanelTabs.tsx | 365 ++++++++++++++++++ .../profile/ui/UserProfilePanelUtils.ts | 2 +- desktop/tests/e2e/profile.spec.ts | 51 +-- 7 files changed, 538 insertions(+), 238 deletions(-) create mode 100644 desktop/src/features/profile/ui/UserProfilePanelTabs.tsx diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 1ecf8b380..0f22ef438 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -831,12 +831,14 @@ export function UserProfilePanel({ > {view === "summary" ? ( setAddToChannelOpen(true)} onOpenActivity={handleOpenActivity} - onOpenAgentConfiguration={() => setView("configuration")} - onOpenChannels={() => setView("channels")} + onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} - onOpenMemories={() => setView("memories")} onOpenDm={onOpenDm} presenceStatus={presenceStatus} profile={profile} @@ -884,10 +886,8 @@ export function UserProfilePanel({ {view === "configuration" ? ( ) : null} {view === "diagnostics" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx index 616acba11..fd42639ae 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx @@ -15,16 +15,12 @@ export const AGENT_DETAILS_FIELD_LABELS = new Set([ export function AgentConfigurationFocusedView({ fields, - instruction, managedAgent, modelLabel, - onEditInstruction, }: { fields: ProfileField[]; - instruction: string | null; managedAgent: ManagedAgent | undefined; modelLabel: string; - onEditInstruction?: () => void; }) { const runtimeConfigurationFields = fields.filter((field) => AGENT_DETAILS_FIELD_LABELS.has(field.label), @@ -34,10 +30,8 @@ export function AgentConfigurationFocusedView({
@@ -153,7 +147,11 @@ function hasAgentConfigurationRows({ ); } -function AgentInstructionRow({ instruction }: { instruction: string | null }) { +export function AgentInstructionRow({ + instruction, +}: { + instruction: string | null; +}) { const trimmedInstruction = instruction?.trim() ?? ""; return ( @@ -189,7 +187,10 @@ function AgentInstructionRow({ instruction }: { instruction: string | null }) { function AgentModelRow({ modelLabel }: { modelLabel: string }) { return ( -
+
diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 3b442a1db..45c5b62c6 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -50,7 +50,6 @@ export type ProfileField = { const AGENT_INFO_LABELS = new Set([ "Public key", "Owned by", - "Owned by & responds to", "NIP-05", "Agent type", "Capabilities", @@ -61,6 +60,7 @@ const AGENT_SETTINGS_LABELS = new Set([ "Respond to", "ACP command", "MCP command", + "Start on launch", ]); const DIAGNOSTICS_LABELS = new Set(["Status", "Last error"]); @@ -252,12 +252,8 @@ export function buildOwnerFields({ relayAgent: RelayAgent | undefined; }): ProfileField[] { const fields: ProfileField[] = []; - const combinesOwnerRespondTo = - managedAgent?.respondTo === "owner-only" && Boolean(ownerDisplayName); - const respondToOwner = - managedAgent?.respondTo === "owner-only" && ownerDisplayName; const respondToDisplayValue = managedAgent - ? respondToOwner + ? managedAgent.respondTo === "owner-only" && ownerDisplayName ? ownerDisplayName : managedAgent.respondTo.replace(/-/g, " ") : null; @@ -302,20 +298,12 @@ export function buildOwnerFields({ ), icon: UserRound, - label: combinesOwnerRespondTo ? "Owned by & responds to" : "Owned by", + label: "Owned by", testId: "user-profile-owned-by", }); } if (!includeOperationalFields) { - if (managedAgent && !respondToOwner && respondToDisplayValue) { - fields.push({ - displayValue: respondToDisplayValue, - icon: Ear, - label: "Respond to", - testId: "user-profile-respond-to", - }); - } return fields; } @@ -419,7 +407,7 @@ export function buildOwnerFields({ label: "Start on launch", testId: "user-profile-start-on-launch", }); - if (!respondToOwner && respondToDisplayValue) { + if (respondToDisplayValue) { fields.push({ displayValue: respondToDisplayValue, icon: Ear, @@ -445,19 +433,14 @@ export function buildOwnerFields({ function orderProfileFields(fields: ProfileField[]) { const publicKeyLabel = "Public key"; const ownedByLabel = "Owned by"; - const ownedByRespondsToLabel = "Owned by & responds to"; const statusLabel = "Status"; return [ ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter( - (field) => - field.label === ownedByLabel || field.label === ownedByRespondsToLabel, - ), + ...fields.filter((field) => field.label === ownedByLabel), ...fields.filter( (field) => field.label !== publicKeyLabel && field.label !== ownedByLabel && - field.label !== ownedByRespondsToLabel && field.copyValue, ), ...fields.filter((field) => field.label === statusLabel), @@ -465,7 +448,6 @@ function orderProfileFields(fields: ProfileField[]) { if ( field.label === publicKeyLabel || field.label === ownedByLabel || - field.label === ownedByRespondsToLabel || field.label === statusLabel ) { return false; diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 96cbd05d2..3ec0979ca 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -1,22 +1,16 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; import { - Activity, ArrowUpRight, - Brain, ChevronDown, - ChevronRight, ChevronUp, CircleAlert, - Cpu, - Hash, MessageSquare, Pencil, Play, Square, UserMinus, UserPlus, - Wrench, } from "lucide-react"; import { toast } from "sonner"; @@ -36,16 +30,20 @@ import type { import { type ProfileField, ProfileFieldGroup, - ProfileFieldRows, } from "@/features/profile/ui/UserProfilePanelFields"; +import { AGENT_DETAILS_FIELD_LABELS } from "@/features/profile/ui/UserProfilePanelAgentDetails"; import { - AGENT_DETAILS_FIELD_LABELS, - AgentDetailsRows, -} from "@/features/profile/ui/UserProfilePanelAgentDetails"; + ProfileInfoTabContent, + ProfileIngressRow, + ProfileRuntimeTabContent, + ProfileTabBar, + type ProfileTab, +} from "@/features/profile/ui/UserProfilePanelTabs"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { ProfileChannelLink } from "@/features/profile/ui/UserProfilePanelUtils"; import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; @@ -55,11 +53,13 @@ import { Badge } from "@/shared/ui/badge"; // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { + canAddToChannel: boolean; canEditAgent: boolean; canOpenAgentLogs: boolean; canViewActivity: boolean; channelCount: number; channelIdToName: Record; + channels: ProfileChannelLink[]; channelsLoading: boolean; displayName: string; followMutation: ReturnType; @@ -78,15 +78,15 @@ export type ProfileSummaryViewProps = { managedAgent: ManagedAgent | undefined; memoriesLoading: boolean; memoryCount: number | undefined; + modelLabel: string; agentInfoFields: ProfileField[]; agentSettingsFields: ProfileField[]; diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; + onAddToChannel: () => void; onOpenActivity: () => void; - onOpenAgentConfiguration: () => void; - onOpenChannels: () => void; + onOpenChannel: (channelId: string) => void; onOpenDiagnostics: () => void; - onOpenMemories: () => void; onOpenDm?: (pubkeys: string[]) => void; presenceStatus: "online" | "away" | "offline" | undefined; profile: ReturnType["data"]; @@ -97,11 +97,13 @@ export type ProfileSummaryViewProps = { }; export function ProfileSummaryView({ + canAddToChannel, canEditAgent, canOpenAgentLogs, canViewActivity, channelCount, channelIdToName, + channels, channelsLoading, displayName, followMutation, @@ -120,15 +122,15 @@ export function ProfileSummaryView({ managedAgent, memoriesLoading, memoryCount, + modelLabel, agentInfoFields, agentSettingsFields, diagnosticsFields, diagnosticsSummary, + onAddToChannel, onOpenActivity, - onOpenAgentConfiguration, - onOpenChannels, + onOpenChannel, onOpenDiagnostics, - onOpenMemories, onOpenDm, presenceStatus, profile, @@ -139,31 +141,38 @@ export function ProfileSummaryView({ }: ProfileSummaryViewProps) { const { goChannel } = useAppNavigation(); const activeTurns = useActiveAgentTurns(isBot ? pubkey : null); + const [activeTab, setActiveTab] = React.useState("info"); - const showMemoriesIngress = isOwner === true && Boolean(pubkey); - const showInstructionIngress = + const showMemoriesTab = isOwner === true && Boolean(pubkey); + const showInstructionBlock = isOwner === true && (agentInstruction !== null || handleEditPersona !== undefined); - const showChannelsIngress = + const showChannelsTab = channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined; - const showModelIngress = isOwner === true && isBot; const runtimeConfigurationFields = agentSettingsFields.filter((field) => AGENT_DETAILS_FIELD_LABELS.has(field.label), ); - const summaryAgentDetailFields = agentSettingsFields.filter( + const runtimeSettingsFields = agentSettingsFields.filter( (field) => !AGENT_DETAILS_FIELD_LABELS.has(field.label), ); - const showRuntimeConfigurationIngress = + const showRuntimeTab = isOwner === true && isBot && - (showInstructionIngress || - showModelIngress || - runtimeConfigurationFields.length > 0); - const showAgentSettingsRows = summaryAgentDetailFields.length > 0; - const showAgentConfigurationRows = showAgentSettingsRows; + (runtimeConfigurationFields.length > 0 || + runtimeSettingsFields.length > 0 || + managedAgent !== undefined || + modelLabel.trim().length > 0 || + diagnosticsFields.length > 0 || + canOpenAgentLogs); const showDiagnosticsIngress = diagnosticsFields.length > 0 || canOpenAgentLogs; const showActivityIngress = canViewActivity; + const showInfoTab = + showInstructionBlock || + agentInfoFields.length > 0 || + showActivityIngress || + !showRuntimeTab; + const diagnosticsStatusField = diagnosticsFields.find( (field) => field.label === "Status", ); @@ -178,13 +187,61 @@ export function ProfileSummaryView({ ) : ( (diagnosticsStatusField?.displayNode ?? diagnosticsSummary ?? "View") ); - const topLevelAgentInfoFields = agentInfoFields.filter( - (field) => - field.label === "Public key" || - field.label === "Owned by" || - field.label === "Owned by & responds to", - ); - const showTopLevelAgentInfo = topLevelAgentInfoFields.length > 0; + + const tabs = React.useMemo(() => { + const items: Array<{ + id: ProfileTab; + label: string; + trailing?: React.ReactNode; + }> = []; + if (showInfoTab) { + items.push({ id: "info", label: "Info" }); + } + if (showRuntimeTab) { + items.push({ id: "runtime", label: "Runtime" }); + } + if (showChannelsTab) { + items.push({ + id: "channels", + label: "Channels", + trailing: channelsLoading + ? "…" + : channelCount > 0 + ? String(channelCount) + : undefined, + }); + } + if (showMemoriesTab) { + items.push({ + id: "memories", + label: "Memories", + trailing: memoriesLoading + ? "…" + : memoryCount !== undefined + ? String(memoryCount) + : undefined, + }); + } + return items; + }, [ + channelCount, + channelsLoading, + memoriesLoading, + memoryCount, + showChannelsTab, + showInfoTab, + showMemoriesTab, + showRuntimeTab, + ]); + + React.useEffect(() => { + if (tabs.some((tab) => tab.id === activeTab)) { + return; + } + setActiveTab(tabs[0]?.id ?? "info"); + }, [activeTab, tabs]); + + const showTabSection = tabs.length > 0; return (
@@ -244,73 +301,51 @@ export function ProfileSummaryView({
) : null} - {showAgentConfigurationRows || - showMemoriesIngress || - showChannelsIngress || - showRuntimeConfigurationIngress || - showDiagnosticsIngress || - showActivityIngress || - showTopLevelAgentInfo ? ( -
- {showAgentConfigurationRows || - showRuntimeConfigurationIngress || - showTopLevelAgentInfo ? ( -
- {showTopLevelAgentInfo ? ( - - ) : null} - - {showRuntimeConfigurationIngress ? ( - - ) : null} -
- ) : null} - {showMemoriesIngress ? ( - + + {activeTab === "info" ? ( + ) : null} - {showChannelsIngress ? ( - 0 - ? String(channelCount) - : "None" - } + {activeTab === "runtime" ? ( + ) : null} - {showDiagnosticsIngress ? ( - ) : null} - {showActivityIngress ? ( - ) : null}
@@ -319,25 +354,6 @@ export function ProfileSummaryView({ ); } -function AdvancedDetailsRow({ onClick }: { onClick: () => void }) { - return ( - - ); -} - function ProfileWorkingBadge({ channelId, name, @@ -672,59 +688,15 @@ function ProfileQuickAction({ ); } -// ── Ingress rows ───────────────────────────────────────────────────────────── - -function ProfileIngressRow({ - disabled, - icon: Icon, - label, - onClick, - testId, - trailing, -}: { - disabled?: boolean; - icon: LucideIcon; - label: string; - onClick: () => void; - testId: string; - trailing?: React.ReactNode; -}) { - const trailingTitle = typeof trailing === "string" ? trailing : undefined; - - return ( - - ); -} - // ── Focused views ──────────────────────────────────────────────────────────── export function MemoryFocusedView({ agentPubkey, + variant = "focused", viewerIsOwner, }: { agentPubkey: string; + variant?: "embedded" | "focused"; viewerIsOwner: boolean | undefined; }) { if (viewerIsOwner !== true) { @@ -732,17 +704,12 @@ export function MemoryFocusedView({ } return ( -
+
); } -type ProfileChannelLink = { - id: string; - name: string; -}; - export function ChannelsFocusedView({ canAddToChannel, channels, @@ -750,6 +717,7 @@ export function ChannelsFocusedView({ isLoading, onAddToChannel, onOpenChannel, + variant = "focused", }: { canAddToChannel: boolean; channels: ProfileChannelLink[]; @@ -757,9 +725,10 @@ export function ChannelsFocusedView({ isLoading: boolean; onAddToChannel: () => void; onOpenChannel: (channelId: string) => void; + variant?: "embedded" | "focused"; }) { return ( -
+
{canAddToChannel ? ( void; + testId: string; + trailing?: React.ReactNode; +}) { + const trailingTitle = typeof trailing === "string" ? trailing : undefined; + + return ( + + ); +} + +function useHorizontalDragScroll() { + const scrollRef = React.useRef(null); + const didDragRef = React.useRef(false); + const momentumFrameRef = React.useRef(null); + const activeListenersRef = React.useRef<{ + move: (event: PointerEvent) => void; + up: (event: PointerEvent) => void; + } | null>(null); + + const stopMomentum = React.useCallback(() => { + if (momentumFrameRef.current !== null) { + cancelAnimationFrame(momentumFrameRef.current); + momentumFrameRef.current = null; + } + }, []); + + const cleanupListeners = React.useCallback(() => { + const active = activeListenersRef.current; + if (!active) { + return; + } + + window.removeEventListener("pointermove", active.move); + window.removeEventListener("pointerup", active.up); + window.removeEventListener("pointercancel", active.up); + activeListenersRef.current = null; + }, []); + + React.useEffect(() => { + return () => { + cleanupListeners(); + stopMomentum(); + }; + }, [cleanupListeners, stopMomentum]); + + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + const element = scrollRef.current; + if (!element || event.button !== 0) { + return; + } + + cleanupListeners(); + stopMomentum(); + + const startX = event.clientX; + const startScrollLeft = element.scrollLeft; + let lastX = event.clientX; + let lastTime = performance.now(); + let velocity = 0; + didDragRef.current = false; + + const handleMove = (moveEvent: PointerEvent) => { + const now = performance.now(); + const deltaX = moveEvent.clientX - startX; + if (!didDragRef.current && Math.abs(deltaX) > 4) { + didDragRef.current = true; + } + + if (didDragRef.current) { + moveEvent.preventDefault(); + element.scrollLeft = startScrollLeft - deltaX; + + const dt = now - lastTime; + if (dt > 0) { + velocity = -(moveEvent.clientX - lastX) / dt; + } + lastX = moveEvent.clientX; + lastTime = now; + } + }; + + const handleUp = () => { + cleanupListeners(); + window.setTimeout(() => { + didDragRef.current = false; + }, 0); + + const minVelocity = 0.02; + if (!didDragRef.current || Math.abs(velocity) < minVelocity) { + return; + } + + let frameTime = performance.now(); + const frictionPerMs = 0.004; + + const step = (now: number) => { + const dt = now - frameTime; + frameTime = now; + + const maxScroll = element.scrollWidth - element.clientWidth; + element.scrollLeft = Math.max( + 0, + Math.min(maxScroll, element.scrollLeft + velocity * dt), + ); + + if (element.scrollLeft <= 0 || element.scrollLeft >= maxScroll) { + momentumFrameRef.current = null; + return; + } + + velocity *= Math.exp(-frictionPerMs * dt); + if (Math.abs(velocity) >= minVelocity) { + momentumFrameRef.current = requestAnimationFrame(step); + } else { + momentumFrameRef.current = null; + } + }; + + momentumFrameRef.current = requestAnimationFrame(step); + }; + + activeListenersRef.current = { move: handleMove, up: handleUp }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleUp); + }, + [cleanupListeners, stopMomentum], + ); + + return { + didDragRef, + onPointerDown: handlePointerDown, + scrollRef, + }; +} + +export function ProfileTabBar({ + activeTab, + onTabChange, + tabs, +}: { + activeTab: ProfileTab; + onTabChange: (tab: ProfileTab) => void; + tabs: Array<{ + id: ProfileTab; + label: string; + trailing?: React.ReactNode; + }>; +}) { + const { didDragRef, onPointerDown, scrollRef } = useHorizontalDragScroll(); + + return ( +
+
+ {tabs.map((tab) => { + const isActive = activeTab === tab.id; + + return ( + + ); + })} +
+
+ ); +} + +export function ProfileInfoTabContent({ + agentInfoFields, + agentInstruction, + onOpenActivity, + pubkey, + showActivityIngress, + showInstructionBlock, +}: { + agentInfoFields: ProfileField[]; + agentInstruction: string | null; + onOpenActivity: () => void; + pubkey: string | null; + showActivityIngress: boolean; + showInstructionBlock: boolean; +}) { + const hasInfoFields = agentInfoFields.length > 0; + + if (!showInstructionBlock && !hasInfoFields && !showActivityIngress) { + return null; + } + + return ( +
+ {showInstructionBlock ? ( +
+ +
+ ) : null} + {hasInfoFields ? : null} + {showActivityIngress ? ( + + ) : null} +
+ ); +} + +export function ProfileRuntimeTabContent({ + diagnosticsFields, + diagnosticsSummary, + managedAgent, + modelLabel, + onOpenDiagnostics, + runtimeConfigurationFields, + runtimeSettingsFields, + showDiagnosticsIngress, +}: { + diagnosticsFields: ProfileField[]; + diagnosticsSummary: React.ReactNode; + managedAgent: ManagedAgent | undefined; + modelLabel: string; + onOpenDiagnostics: () => void; + runtimeConfigurationFields: ProfileField[]; + runtimeSettingsFields: ProfileField[]; + showDiagnosticsIngress: boolean; +}) { + const detailDiagnosticsFields = diagnosticsFields.filter( + (field) => field.label !== "Last error", + ); + const hasRuntimeRows = + runtimeConfigurationFields.length > 0 || + runtimeSettingsFields.length > 0 || + managedAgent !== undefined || + modelLabel.trim().length > 0; + + if ( + !hasRuntimeRows && + detailDiagnosticsFields.length === 0 && + !showDiagnosticsIngress + ) { + return null; + } + + return ( +
+ {hasRuntimeRows ? ( +
+ + {runtimeSettingsFields.length > 0 ? ( + + ) : null} +
+ ) : null} + {detailDiagnosticsFields.length > 0 ? ( + + ) : null} + {showDiagnosticsIngress ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index b11a5d5c3..ea7905ea5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -27,7 +27,7 @@ export type ProfilePanelView = export const PROFILE_PANEL_VIEW_TITLES: Record = { summary: "Profile", info: "Agent info", - configuration: "Advanced", + configuration: "Runtime", diagnostics: "Diagnostics", memories: "Memories", channels: "Channels", diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index f25ef096e..9aacc6d2d 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -671,59 +671,42 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a await messageRow.locator("button").first().click(); await expect(page.getByTestId("user-profile-panel")).toBeVisible(); - const openSubview = async (testId: string, title: string) => { - await page.getByTestId(testId).click(); - await expect( - page.getByRole("heading", { level: 2, name: title }), - ).toBeVisible(); - }; - const backToProfile = async () => { - await page.getByTestId("user-profile-panel-back").click(); - await expect( - page.getByRole("heading", { level: 2, name: "Profile" }), - ).toBeVisible(); - }; - - await openSubview( - "user-profile-agent-instruction-ingress", - "Agent instruction", - ); + + await expect(page.getByTestId("user-profile-tab-info")).toBeVisible(); await expect( page.getByTestId("user-profile-agent-instruction"), ).toContainText("Watch the channel and help when asked."); - await backToProfile(); - await openSubview("user-profile-model-ingress", "Model"); - await expect( - page.getByRole("heading", { level: 2, name: "Model" }), - ).toBeVisible(); - await backToProfile(); + await page.getByTestId("user-profile-tab-runtime").click(); + await expect(page.getByTestId("user-profile-model")).toBeVisible(); + await expect(page.getByTestId("user-profile-respond-to")).toBeVisible(); - await openSubview("user-profile-agent-settings-ingress", "Agent settings"); + await page.getByTestId("user-profile-settings-menu-trigger").click(); await expect( page.getByTestId(`user-profile-agent-auto-start-${agentPubkey}`), ).toBeVisible(); - await backToProfile(); + await page.keyboard.press("Escape"); - await openSubview("user-profile-diagnostics-ingress", "Diagnostics"); + await page.getByTestId("user-profile-diagnostics-ingress").click(); + await expect( + page.getByRole("heading", { level: 2, name: "Diagnostics" }), + ).toBeVisible(); await page.getByTestId(`user-profile-agent-logs-${agentPubkey}`).click(); await expect( page.getByRole("heading", { level: 2, name: "Harness log" }), ).toBeVisible(); await expect(page.getByTestId("managed-agent-log-content")).toBeVisible(); - await backToProfile(); + await page.getByTestId("user-profile-panel-back").click(); + await expect( + page.getByRole("heading", { level: 2, name: "Profile" }), + ).toBeVisible(); - await openSubview("user-profile-channels-ingress", "Channels"); + await page.getByTestId("user-profile-tab-channels").click(); await expect(page.getByTestId("user-profile-channels-list")).toContainText( "#general", ); - await backToProfile(); - - const memoriesIngress = page.getByTestId("user-profile-memories-ingress"); - await expect(memoriesIngress).toContainText("Memories"); - await expect(memoriesIngress).toContainText("9"); - await memoriesIngress.click(); + await page.getByTestId("user-profile-tab-memories").click(); await expect(page.getByTestId("agent-memory-section")).toBeVisible(); await expect(page.getByTestId("agent-memory-list")).toContainText( "ui-density", From f48f16e717e9d2883d1283077e5054f0bf6ecb93 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 21:54:50 -0700 Subject: [PATCH 14/30] fix(profile): refine profile panel layout - Hide the profile tab bar when Info is the only available section, keeping simple human profiles focused on the visible fields. - Split the agent Status diagnostics row out of the runtime details so it appears above runtime configuration. - Preserve the existing multi-tab agent profile behavior for Runtime, Channels, and Memories. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../profile/ui/UserProfilePanelSections.tsx | 13 ++++++++----- .../features/profile/ui/UserProfilePanelTabs.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 3ec0979ca..9eebad0f1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -242,6 +242,7 @@ export function ProfileSummaryView({ }, [activeTab, tabs]); const showTabSection = tabs.length > 0; + const showTabBar = !(tabs.length === 1 && tabs[0]?.id === "info"); return (
@@ -303,11 +304,13 @@ export function ProfileSummaryView({ {showTabSection ? (
- + {showTabBar ? ( + + ) : null} {activeTab === "info" ? ( field.label === "Status", + ); const detailDiagnosticsFields = diagnosticsFields.filter( - (field) => field.label !== "Last error", + (field) => field.label !== "Last error" && field.label !== "Status", ); const hasRuntimeRows = runtimeConfigurationFields.length > 0 || @@ -327,6 +330,7 @@ export function ProfileRuntimeTabContent({ if ( !hasRuntimeRows && + statusDiagnosticsFields.length === 0 && detailDiagnosticsFields.length === 0 && !showDiagnosticsIngress ) { @@ -335,6 +339,9 @@ export function ProfileRuntimeTabContent({ return (
+ {statusDiagnosticsFields.length > 0 ? ( + + ) : null} {hasRuntimeRows ? (
Date: Wed, 24 Jun 2026 22:01:44 -0700 Subject: [PATCH 15/30] fix(profile): rename diagnostics pane to harness log - Rename the profile Diagnostics focused view and Runtime tab ingress to Harness Log so the user-facing label matches the pane content. - Keep agent Status in the Runtime tab summary area while filtering it out of the focused Harness Log pane. - Normalize Harness Log casing in the managed agent log panel and update the profile E2E assertion for the new pane title and missing Status row. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/agents/ui/ManagedAgentLogPanel.tsx | 4 ++-- .../src/features/profile/ui/UserProfilePanel.tsx | 2 -- .../profile/ui/UserProfilePanelFields.tsx | 9 --------- .../profile/ui/UserProfilePanelSections.tsx | 16 +++++++--------- .../features/profile/ui/UserProfilePanelTabs.tsx | 2 +- .../features/profile/ui/UserProfilePanelUtils.ts | 4 ++-- desktop/tests/e2e/profile.spec.ts | 7 ++----- 7 files changed, 14 insertions(+), 30 deletions(-) diff --git a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx index a2ee14779..39a34a661 100644 --- a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx @@ -41,7 +41,7 @@ export function ManagedAgentLogPanel({

- Harness log + Harness Log

Select a local agent to inspect recent output. @@ -133,7 +133,7 @@ function HarnessLogHeader({

- Harness log + Harness Log setAddToChannelOpen(true)} onOpenActivity={handleOpenActivity} onOpenChannel={handleOpenChannel} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 45c5b62c6..17ce8320a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -128,17 +128,8 @@ export function useProfileFieldBuckets({ }) : []), ]; - const diagnosticsFields = - bucketProfileFields(metadataFields).diagnosticsFields; - return { ...bucketProfileFields(metadataFields), - diagnosticsSummary: - diagnosticsFields.find((field) => field.label === "Status") - ?.displayValue ?? - diagnosticsFields.find((field) => field.label === "Last error") - ?.displayValue ?? - null, modelLabel: managedAgent?.model ?? persona?.model ?? "Auto", }; }, [ diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 9eebad0f1..c466a4051 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -82,7 +82,6 @@ export type ProfileSummaryViewProps = { agentInfoFields: ProfileField[]; agentSettingsFields: ProfileField[]; diagnosticsFields: ProfileField[]; - diagnosticsSummary: string | null; onAddToChannel: () => void; onOpenActivity: () => void; onOpenChannel: (channelId: string) => void; @@ -126,7 +125,6 @@ export function ProfileSummaryView({ agentInfoFields, agentSettingsFields, diagnosticsFields, - diagnosticsSummary, onAddToChannel, onOpenActivity, onOpenChannel, @@ -165,7 +163,8 @@ export function ProfileSummaryView({ diagnosticsFields.length > 0 || canOpenAgentLogs); const showDiagnosticsIngress = - diagnosticsFields.length > 0 || canOpenAgentLogs; + diagnosticsFields.some((field) => field.label !== "Status") || + canOpenAgentLogs; const showActivityIngress = canViewActivity; const showInfoTab = showInstructionBlock || @@ -173,9 +172,6 @@ export function ProfileSummaryView({ showActivityIngress || !showRuntimeTab; - const diagnosticsStatusField = diagnosticsFields.find( - (field) => field.label === "Status", - ); const diagnosticsErrorField = diagnosticsFields.find( (field) => field.label === "Last error", ); @@ -185,7 +181,7 @@ export function ProfileSummaryView({ Error ) : ( - (diagnosticsStatusField?.displayNode ?? diagnosticsSummary ?? "View") + "View" ); const tabs = React.useMemo(() => { @@ -814,9 +810,11 @@ export function DiagnosticsFocusedView({ }) { const hasLog = canOpenAgentLogs && managedAgent !== undefined; const lastErrorField = fields.find((field) => field.label === "Last error"); - const detailFields = fields.filter((field) => field.label !== "Last error"); + const detailFields = fields.filter( + (field) => field.label !== "Last error" && field.label !== "Status", + ); - if (fields.length === 0 && !hasLog) { + if (!lastErrorField && detailFields.length === 0 && !hasLog) { return null; } diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index be963c358..0f3e1d6bc 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -361,7 +361,7 @@ export function ProfileRuntimeTabContent({ {showDiagnosticsIngress ? ( = { summary: "Profile", info: "Agent info", configuration: "Runtime", - diagnostics: "Diagnostics", + diagnostics: "Harness Log", memories: "Memories", channels: "Channels", - logs: "Harness log", + logs: "Harness Log", }; const PROFILE_PANEL_VIEWS = new Set( diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 9aacc6d2d..de12eec69 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -689,12 +689,9 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a await page.getByTestId("user-profile-diagnostics-ingress").click(); await expect( - page.getByRole("heading", { level: 2, name: "Diagnostics" }), - ).toBeVisible(); - await page.getByTestId(`user-profile-agent-logs-${agentPubkey}`).click(); - await expect( - page.getByRole("heading", { level: 2, name: "Harness log" }), + page.getByRole("heading", { level: 2, name: "Harness Log" }), ).toBeVisible(); + await expect(page.getByTestId("user-profile-agent-status")).toHaveCount(0); await expect(page.getByTestId("managed-agent-log-content")).toBeVisible(); await page.getByTestId("user-profile-panel-back").click(); await expect( From ef1a7b0ddd469aed2c698302aeb0ea4460c60395 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 24 Jun 2026 22:10:17 -0700 Subject: [PATCH 16/30] fix(profile): open agent instructions in focused view - Add an `instructions` profile subview so agent instructions use the same body-replacement and back-button pattern as Harness Log. - Change the summary instructions row to a two-line preview with a chevron instead of an in-place scroll/expand region. - Render full instructions Markdown in the focused view while keeping the preview as plain clamped text inside the clickable row. - Extract profile panel header actions into `UserProfilePanelHeaderContent` to keep `UserProfilePanel.tsx` under the desktop file-size guard. - Update profile view parsing tests and the profile E2E flow to cover the new instructions subview. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/UserProfilePanel.tsx | 66 ++++---------- .../ui/UserProfilePanelAgentDetails.tsx | 87 ++++++++++++++++--- .../ui/UserProfilePanelHeaderContent.tsx | 74 ++++++++++++++++ .../profile/ui/UserProfilePanelSections.tsx | 64 +++++++++++++- .../profile/ui/UserProfilePanelTabs.tsx | 9 +- .../profile/ui/UserProfilePanelUtils.test.mjs | 2 +- .../profile/ui/UserProfilePanelUtils.ts | 3 +- desktop/tests/e2e/profile.spec.ts | 42 +++++++-- 8 files changed, 272 insertions(+), 75 deletions(-) create mode 100644 desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 7fdd750e4..5e1692b3a 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, @@ -54,6 +52,7 @@ import { } from "@/features/profile/hooks"; import { AgentInfoFocusedView, + AgentInstructionsFocusedView, ChannelsFocusedView, DiagnosticsFocusedView, MemoryFocusedView, @@ -67,7 +66,6 @@ import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePan import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs"; import { deriveProfileChannels, - PROFILE_PANEL_VIEW_TITLES, type ProfilePanelView, resolveAgentInstruction, resolvePanelProfile, @@ -80,11 +78,7 @@ import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; -import { - AuxiliaryPanelHeaderGroup, - AuxiliaryPanelTitle, - auxiliaryPanelContentPaddingClass, -} from "@/shared/layout/AuxiliaryPanelHeader"; +import { auxiliaryPanelContentPaddingClass } from "@/shared/layout/AuxiliaryPanelHeader"; import { cn } from "@/shared/lib/cn"; import type { AgentPersona, @@ -94,8 +88,8 @@ import type { ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame"; +import { getUserProfilePanelHeaderContent } from "@/features/profile/ui/UserProfilePanelHeaderContent"; export type { ProfilePanelView }; @@ -773,47 +767,15 @@ export function UserProfilePanel({ pubkey: effectivePubkey, relayAgent, }); - const headerLeftContent = ( - - {view !== "summary" ? ( - - ) : null} - - {PROFILE_PANEL_VIEW_TITLES[view]} - - - ); - const headerActions = ( -
- {view === "memories" && viewerIsOwner && effectivePubkey ? ( - - ) : null} - {view === "summary" ? agentSettingsMenu : null} - -
+ const { headerActions, headerLeftContent } = getUserProfilePanelHeaderContent( + { + agentSettingsMenu, + effectivePubkey, + onBack: () => setView("summary"), + onClose, + view, + viewerIsOwner, + }, ); const isDiagnosticsLikeView = view === "diagnostics" || view === "logs"; @@ -863,6 +825,7 @@ export function UserProfilePanel({ onOpenActivity={handleOpenActivity} onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} + onOpenInstructions={() => setView("instructions")} onOpenDm={onOpenDm} presenceStatus={presenceStatus} profile={profile} @@ -888,6 +851,9 @@ export function UserProfilePanel({ modelLabel={modelLabel} /> ) : null} + {view === "instructions" ? ( + + ) : null} {view === "diagnostics" ? ( void; }) { const trimmedInstruction = instruction?.trim() ?? ""; - - return ( -
+ const canOpenInstructions = + trimmedInstruction.length > 0 && onOpenInstructions !== undefined; + const rowContent = ( + <>
Instructions
{trimmedInstruction ? ( -
- -
+ canOpenInstructions ? ( + + {trimmedInstruction} + + ) : ( +
+ +
+ ) ) : (

)}

+ {canOpenInstructions ? ( + + ) : null} + + ); + + if (canOpenInstructions) { + return ( + + ); + } + + return
{rowContent}
; +} + +export function AgentInstructionsFocusedView({ + instruction, +}: { + instruction: string | null; +}) { + const trimmedInstruction = instruction?.trim() ?? ""; + + return ( +
+
+ {trimmedInstruction ? ( + + ) : ( +

+ No instruction set. +

+ )} +
); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx b/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx new file mode 100644 index 000000000..91282c00a --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import { ArrowLeft, X } from "lucide-react"; + +import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; +import { + PROFILE_PANEL_VIEW_TITLES, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; +import { + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, +} from "@/shared/layout/AuxiliaryPanelHeader"; +import { Button } from "@/shared/ui/button"; + +export function getUserProfilePanelHeaderContent({ + agentSettingsMenu, + effectivePubkey, + onBack, + onClose, + view, + viewerIsOwner, +}: { + agentSettingsMenu: ReactNode; + effectivePubkey: string | null; + onBack: () => void; + onClose: () => void; + view: ProfilePanelView; + viewerIsOwner: boolean; +}) { + const headerLeftContent = ( + + {view !== "summary" ? ( + + ) : null} + + {PROFILE_PANEL_VIEW_TITLES[view]} + + + ); + const headerActions = ( +
+ {view === "memories" && viewerIsOwner && effectivePubkey ? ( + + ) : null} + {view === "summary" ? agentSettingsMenu : null} + +
+ ); + + return { headerActions, headerLeftContent }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index c466a4051..8c57f8a0f 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -50,6 +50,8 @@ import { useNow } from "@/shared/lib/useNow"; import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; +export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; + // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { @@ -86,6 +88,7 @@ export type ProfileSummaryViewProps = { onOpenActivity: () => void; onOpenChannel: (channelId: string) => void; onOpenDiagnostics: () => void; + onOpenInstructions: () => void; onOpenDm?: (pubkeys: string[]) => void; presenceStatus: "online" | "away" | "offline" | undefined; profile: ReturnType["data"]; @@ -95,6 +98,52 @@ export type ProfileSummaryViewProps = { userStatus: { text: string; emoji: string } | null | undefined; }; +type RuntimeTabStatus = "running" | "stopped" | "error"; + +function resolveRuntimeTabStatus({ + diagnosticsError, + managedAgent, +}: { + diagnosticsError: boolean; + managedAgent: ManagedAgent | undefined; +}): RuntimeTabStatus | undefined { + if (diagnosticsError || managedAgent?.lastError) { + return "error"; + } + + if (!managedAgent) { + return undefined; + } + + if (managedAgent.status === "running" || managedAgent.status === "deployed") { + return "running"; + } + + return "stopped"; +} + +function RuntimeTabStatusDot({ status }: { status: RuntimeTabStatus }) { + const label = + status === "error" ? "Error" : status === "running" ? "Running" : "Stopped"; + + return ( +