diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..1607684fd 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -1,13 +1,17 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { agentSession?: string; messageId?: string; profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; thread?: string; threadRootId?: string; }; @@ -16,8 +20,8 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function profileViewValue(value: unknown): "memories" | "channels" | undefined { - return value === "memories" || value === "channels" ? value : undefined; +function profileViewValue(value: unknown): ProfilePanelView | undefined { + return parseProfilePanelView(value) ?? undefined; } function validateChannelSearch( diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e1a5a001e..949b56d0b 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -11,7 +15,7 @@ const PulseScreen = React.lazy(async () => { type PulseRouteSearch = { profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; }; function validatePulseSearch( @@ -22,10 +26,7 @@ function validatePulseSearch( typeof search.profile === "string" && search.profile.length > 0 ? search.profile : undefined, - profileView: - search.profileView === "memories" || search.profileView === "channels" - ? search.profileView - : undefined, + profileView: parseProfilePanelView(search.profileView) ?? undefined, }; } diff --git a/desktop/src/features/agents/observerRelayStore.test.mjs b/desktop/src/features/agents/observerRelayStore.test.mjs new file mode 100644 index 000000000..93c04733d --- /dev/null +++ b/desktop/src/features/agents/observerRelayStore.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + isKnownAgentPubkey, + registerKnownAgentPubkeys, + resetAgentObserverStore, + unregisterKnownAgentPubkeys, +} from "./observerRelayStore.ts"; + +const AGENT_A = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const AGENT_B = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const AGENT_C = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +describe("observerRelayStore known agent registrations", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("unions known agents from multiple bridge registrations", () => { + const agentsPage = Symbol("agents-page"); + const profilePanel = Symbol("profile-panel"); + + registerKnownAgentPubkeys(agentsPage, [AGENT_A, AGENT_B]); + registerKnownAgentPubkeys(profilePanel, [AGENT_C]); + + assert.equal(isKnownAgentPubkey(AGENT_A), true); + assert.equal(isKnownAgentPubkey(AGENT_B), true); + assert.equal(isKnownAgentPubkey(AGENT_C), true); + + registerKnownAgentPubkeys(profilePanel, []); + + assert.equal(isKnownAgentPubkey(AGENT_A), true); + assert.equal(isKnownAgentPubkey(AGENT_B), true); + assert.equal(isKnownAgentPubkey(AGENT_C), false); + + unregisterKnownAgentPubkeys(agentsPage); + + assert.equal(isKnownAgentPubkey(AGENT_A), false); + assert.equal(isKnownAgentPubkey(AGENT_B), false); + }); +}); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 6f3370de0..6e9251a46 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -47,7 +47,7 @@ const snapshotByAgent = new Map(); // We key each subscriber's contribution in `knownAgentsBySubscription` and // recompute the union, so co-mounted callers no longer clobber each other. const knownAgentPubkeys = new Set(); -const knownAgentsBySubscription = new Map>(); +const knownAgentsBySubscription = new Map>(); function recomputeKnownAgentPubkeys() { knownAgentPubkeys.clear(); @@ -58,8 +58,8 @@ function recomputeKnownAgentPubkeys() { } } -function registerKnownAgents( - subscriptionId: string, +export function registerKnownAgentPubkeys( + subscriptionId: string | symbol, pubkeys: readonly string[], ) { knownAgentsBySubscription.set( @@ -69,12 +69,16 @@ function registerKnownAgents( recomputeKnownAgentPubkeys(); } -function unregisterKnownAgents(subscriptionId: string) { +export function unregisterKnownAgentPubkeys(subscriptionId: string | symbol) { if (knownAgentsBySubscription.delete(subscriptionId)) { recomputeKnownAgentPubkeys(); } } +export function isKnownAgentPubkey(pubkey: string) { + return knownAgentPubkeys.has(normalizePubkey(pubkey)); +} + let connectionState: ConnectionState = "idle"; let errorMessage: string | null = null; let unsubscribeRelay: (() => Promise) | null = null; @@ -176,7 +180,7 @@ async function handleRelayObserverEvent( // Verify agent is known/trusted before decrypting. // Silently drop events from agents we are not managing. - if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { + if (!isKnownAgentPubkey(agentPubkey)) { return; } @@ -326,9 +330,9 @@ export function useManagedAgentObserverBridge( // own agent list. The store recomputes the union across all subscribers, so // a co-mounted caller no longer wipes out this caller's agents. React.useEffect(() => { - registerKnownAgents(subscriptionId, agentPubkeys); + registerKnownAgentPubkeys(subscriptionId, agentPubkeys); return () => { - unregisterKnownAgents(subscriptionId); + unregisterKnownAgentPubkeys(subscriptionId); }; }, [subscriptionId, agentPubkeys]); diff --git a/desktop/src/features/agents/ui/AgentGroupRows.tsx b/desktop/src/features/agents/ui/AgentGroupRows.tsx index 2aa95372e..a8131906c 100644 --- a/desktop/src/features/agents/ui/AgentGroupRows.tsx +++ b/desktop/src/features/agents/ui/AgentGroupRows.tsx @@ -6,7 +6,6 @@ export type AgentGroupRowsProps = { agents: ManagedAgent[]; channelIdToName: Record; channelsByPubkey: Record; - isActionPending: boolean; logContent: string | null; logError: Error | null; logLoading: boolean; @@ -14,19 +13,14 @@ export type AgentGroupRowsProps = { presenceLoaded: boolean; presenceLookup: PresenceLookup; selectedLogAgentPubkey: string | null; - onAddToChannel: (agent: ManagedAgent) => void; - onDelete: (pubkey: string) => void; + onOpenProfile: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; - onStart: (pubkey: string) => void; - onStop: (pubkey: string) => void; - onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; }; export function AgentGroupRows({ agents, channelIdToName, channelsByPubkey, - isActionPending, logContent, logError, logLoading, @@ -34,12 +28,8 @@ export function AgentGroupRows({ presenceLoaded, presenceLookup, selectedLogAgentPubkey, - onAddToChannel, - onDelete, + onOpenProfile, onSelectLogAgent, - onStart, - onStop, - onToggleStartOnAppLaunch, }: AgentGroupRowsProps) { return (
@@ -48,7 +38,6 @@ export function AgentGroupRows({ agent={agent} channelIdToName={channelIdToName} channelNames={channelsByPubkey[normalizePubkey(agent.pubkey)] ?? []} - isActionPending={isActionPending} isLogSelected={selectedLogAgentPubkey === agent.pubkey} key={agent.pubkey} logContent={ @@ -59,12 +48,8 @@ export function AgentGroupRows({ personaLabelsById={personaLabelsById} presenceLoaded={presenceLoaded} presenceLookup={presenceLookup} - onAddToChannel={onAddToChannel} - onDelete={onDelete} + onOpenProfile={onOpenProfile} onSelectLogAgent={onSelectLogAgent} - onStart={onStart} - onStop={onStop} - onToggleStartOnAppLaunch={onToggleStartOnAppLaunch} /> ))}
diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 9bcedca67..dadb07f31 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -1,5 +1,12 @@ import * as React from "react"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { AgentPersona } from "@/shared/api/types"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const AgentsView = React.lazy(async () => { @@ -7,12 +14,63 @@ const AgentsView = React.lazy(async () => { return { default: module.AgentsView }; }); +type ProfilePanelTarget = + | { kind: "pubkey"; pubkey: string } + | { kind: "persona"; persona: AgentPersona }; + export function AgentsScreen() { + const identityQuery = useIdentityQuery(); + const [profilePanelTarget, setProfilePanelTarget] = + React.useState(null); + const threadPanelWidth = useThreadPanelWidth(); + const openDmMutation = useOpenDmMutation(); + const { goChannel } = useAppNavigation(); + + const handleOpenDm = React.useCallback( + async (pubkeys: string[]) => { + const dm = await openDmMutation.mutateAsync({ pubkeys }); + await goChannel(dm.id); + }, + [goChannel, openDmMutation], + ); + return ( -
- }> - - -
+ + setProfilePanelTarget({ kind: "persona", persona }) + } + onOpenProfilePanel={(pubkey) => + setProfilePanelTarget({ kind: "pubkey", pubkey }) + } + > +
+
+ }> + + + {profilePanelTarget ? ( + setProfilePanelTarget(null)} + onOpenDm={handleOpenDm} + onResetWidth={threadPanelWidth.onResetWidth} + onResizeStart={threadPanelWidth.onResizeStart} + persona={ + profilePanelTarget.kind === "persona" + ? profilePanelTarget.persona + : undefined + } + pubkey={ + profilePanelTarget.kind === "pubkey" + ? profilePanelTarget.pubkey + : undefined + } + widthPx={threadPanelWidth.widthPx} + /> + ) : null} +
+
+
); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 44088d490..f80207beb 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -22,8 +22,10 @@ import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; export function AgentsView() { + const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); const agents = useManagedAgentActions(); const personas = usePersonaActions(); const teamActions = useTeamActions( @@ -83,11 +85,6 @@ export function AgentsView() { personaLabelsById={personas.personaLabelsById} presenceLoaded={agents.managedPresenceQuery.isSuccess} presenceLookup={agents.managedPresenceQuery.data ?? {}} - onAddToChannel={(agent) => { - agents.setActionNoticeMessage(null); - agents.setActionErrorMessage(null); - agents.setAgentToAddToChannel(agent); - }} onBulkRemoveStopped={() => { void agents.handleBulkRemoveStopped(); }} @@ -97,22 +94,13 @@ export function AgentsView() { onCreateAgent={() => { agents.setIsCreateOpen(true); }} - onDeleteAgent={(pubkey) => { - void agents.handleDelete(pubkey); - }} - onSelectLogAgent={agents.setLogAgentPubkey} - onStartAgent={(pubkey) => { - void agents.handleStart(pubkey); - }} - onStopAgent={(pubkey) => { - void agents.handleStop(pubkey); + onOpenAgentProfile={(pubkey) => { + openProfilePanel?.(pubkey); }} - onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => { - void agents.handleToggleStartOnAppLaunch( - pubkey, - startOnAppLaunch, - ); + onOpenPersonaProfile={(persona) => { + openPersonaProfilePanel?.(persona); }} + onSelectLogAgent={agents.setLogAgentPubkey} selectedLogAgentPubkey={agents.logAgentPubkey} // Persona props canChooseCatalog={personas.catalogPersonas.length > 0} @@ -136,13 +124,6 @@ export function AgentsView() { isPersonasPending={personas.isPending} onCreatePersona={personas.openCreate} onChooseCatalog={personas.openCatalog} - onDuplicatePersona={personas.openDuplicate} - onEditPersona={personas.openEdit} - onExportPersona={personas.handleExport} - onDeactivatePersona={(persona) => { - void personas.handleSetActive(persona, false, "library"); - }} - onDeletePersona={personas.openDelete} onImportPersonaFile={(fileBytes, fileName) => { void personas.handleImportFile(fileBytes, fileName); }} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 8769737c6..c7de8fafb 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -1,20 +1,6 @@ import * as React from "react"; -import { - AlertTriangle, - ChevronDown, - ChevronRight, - Clipboard, - Ellipsis, - FileText, - Pencil, - Play, - Power, - Square, - Trash2, - UserPlus, -} from "lucide-react"; -import { toast } from "sonner"; +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -29,24 +15,15 @@ import type { PresenceStatus, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; -import { EditAgentDialog } from "./EditAgentDialog"; +import { Button } from "@/shared/ui/button"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; -import { ModelPicker } from "./ModelPicker"; import { truncatePubkey } from "./agentUi"; export function ManagedAgentRow({ agent, channelIdToName, channelNames, - isActionPending, isLogSelected, logContent, logError, @@ -54,17 +31,12 @@ export function ManagedAgentRow({ personaLabelsById, presenceLoaded, presenceLookup, - onAddToChannel, - onDelete, + onOpenProfile, onSelectLogAgent, - onStart, - onStop, - onToggleStartOnAppLaunch, }: { agent: ManagedAgent; channelIdToName: Record; channelNames: { id: string; name: string }[]; - isActionPending: boolean; isLogSelected: boolean; logContent: string | null; logError: Error | null; @@ -72,14 +44,9 @@ export function ManagedAgentRow({ personaLabelsById: Record; presenceLoaded: boolean; presenceLookup: PresenceLookup; - onAddToChannel: (agent: ManagedAgent) => void; - onDelete: (pubkey: string) => void; + onOpenProfile: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; - onStart: (pubkey: string) => void; - onStop: (pubkey: string) => void; - onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; }) { - const isActive = agent.status === "running" || agent.status === "deployed"; const isLocal = agent.backend.type === "local"; const runtimeSource = agent.backend.type === "provider" ? `Remote (${agent.backend.id})` : null; @@ -181,18 +148,14 @@ export function ManagedAgentRow({ )}
- - onSelectLogAgent(pubkey)} - onStart={onStart} - onStop={onStop} - onToggleStartOnAppLaunch={onToggleStartOnAppLaunch} - /> +
@@ -274,12 +237,6 @@ function AgentSummary({ Remote deployment )} - {agent.personaOutOfDate ? ( -

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

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

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

+ ) : null} {activeWorkingChannels.length > 0 ? (
{activeWorkingChannels.map((channel) => ( @@ -414,151 +377,6 @@ function RuntimeBlock({ ); } -function AgentActionsMenu({ - agent, - isActionPending, - isActive, - onAddToChannel, - onDelete, - onOpenLogs, - onStart, - onStop, - onToggleStartOnAppLaunch, -}: { - agent: ManagedAgent; - isActionPending: boolean; - isActive: boolean; - onAddToChannel: (agent: ManagedAgent) => void; - onDelete: (pubkey: string) => void; - onOpenLogs: (pubkey: string) => void; - onStart: (pubkey: string) => void; - onStop: (pubkey: string) => void; - onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; -}) { - const [editOpen, setEditOpen] = React.useState(false); - - return ( - <> - - - - - event.preventDefault()} - > - {agent.backend.type === "provider" ? ( - <> - onStart(agent.pubkey)} - > - - {isActive ? "Redeploy" : "Deploy"} - - onStop(agent.pubkey)} - > - - Shutdown - - - ) : isActive ? ( - onStop(agent.pubkey)} - > - - Stop - - ) : ( - onStart(agent.pubkey)} - > - - Spawn - - )} - - {agent.backend.type !== "provider" ? ( - setEditOpen(true)}> - - Edit - - ) : null} - - onAddToChannel(agent)} - > - - Add to channel - - - { - await navigator.clipboard.writeText(agent.pubkey); - toast.success("Copied pubkey to clipboard"); - }} - > - - Copy pubkey - - - {agent.backend.type === "local" ? ( - onOpenLogs(agent.pubkey)}> - - View logs - - ) : null} - - {agent.backend.type === "local" ? ( - - onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch) - } - > - - {agent.startOnAppLaunch - ? "Disable auto-start" - : "Enable auto-start"} - - ) : null} - - - - onDelete(agent.pubkey)} - > - - Delete - - - - - {editOpen ? ( - - ) : null} - - ); -} - function AgentOriginBadge({ agent }: { agent: ManagedAgent }) { return ( diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index eede54956..eceb9d3e2 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -28,7 +28,6 @@ import { } from "@/shared/ui/dropdown-menu"; import { Skeleton } from "@/shared/ui/skeleton"; import { AgentGroupRows } from "./AgentGroupRows"; -import { PersonaActionsMenu } from "./PersonaActionsMenu"; import { PersonaIdentity } from "./PersonaIdentity"; import { PersonaLibraryEntryPoints } from "./PersonaLibraryEntryPoints"; @@ -47,15 +46,12 @@ type UnifiedAgentsSectionProps = { personaLabelsById: Record; presenceLoaded: boolean; presenceLookup: PresenceLookup; - onAddToChannel: (agent: ManagedAgent) => void; onBulkRemoveStopped: () => void; onBulkStopRunning: () => void; onCreateAgent: () => void; - onDeleteAgent: (pubkey: string) => void; + onOpenAgentProfile: (pubkey: string) => void; + onOpenPersonaProfile: (persona: AgentPersona) => void; onSelectLogAgent: (pubkey: string | null) => void; - onStartAgent: (pubkey: string) => void; - onStopAgent: (pubkey: string) => void; - onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; selectedLogAgentPubkey: string | null; canChooseCatalog: boolean; personas: AgentPersona[]; @@ -66,11 +62,6 @@ type UnifiedAgentsSectionProps = { isPersonasPending: boolean; onCreatePersona: () => void; onChooseCatalog: () => void; - onDuplicatePersona: (persona: AgentPersona) => void; - onEditPersona: (persona: AgentPersona) => void; - onExportPersona: (persona: AgentPersona) => void; - onDeactivatePersona: (persona: AgentPersona) => void; - onDeletePersona: (persona: AgentPersona) => void; onImportPersonaFile: (fileBytes: number[], fileName: string) => void; }; @@ -120,15 +111,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { personaLabelsById, presenceLoaded, presenceLookup, - onAddToChannel, onBulkRemoveStopped, onBulkStopRunning, onCreateAgent, - onDeleteAgent, + onOpenAgentProfile, + onOpenPersonaProfile, onSelectLogAgent, - onStartAgent, - onStopAgent, - onToggleStartOnAppLaunch, selectedLogAgentPubkey, canChooseCatalog, personas, @@ -139,11 +127,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPersonasPending, onCreatePersona, onChooseCatalog, - onDuplicatePersona, - onEditPersona, - onExportPersona, - onDeactivatePersona, - onDeletePersona, onImportPersonaFile, } = props; @@ -188,12 +171,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { presenceLoaded, presenceLookup, selectedLogAgentPubkey, - onAddToChannel, - onDelete: onDeleteAgent, + onOpenProfile: onOpenAgentProfile, onSelectLogAgent, - onStart: onStartAgent, - onStop: onStopAgent, - onToggleStartOnAppLaunch, } as const; return ( @@ -277,16 +256,15 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ) : !hasAgents ? ( Inactive ) : null} - +
{!isCollapsed && hasAgents ? ( diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 588a1e6df..e51041f3c 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanel"; +import { + profilePanelViewFromSearch, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { type HistorySearchSetterOptions, useHistorySearchState, @@ -36,10 +39,6 @@ const CHANNEL_SEARCH_KEYS = [ const CHANNEL_MANAGEMENT_OPEN_VALUE = "1"; -function asProfilePanelView(value: string | null): ProfilePanelView { - return value === "memories" || value === "channels" ? value : "summary"; -} - export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -88,7 +87,7 @@ export function useChannelPanelHistoryState() { openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, - profilePanelView: asProfilePanelView(values.profileView), + profilePanelView: profilePanelViewFromSearch(values.profileView), setChannelManagementOpen, setOpenAgentSessionPubkey, setOpenThreadHeadId, diff --git a/desktop/src/features/profile/ui/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/UserProfileCreatedAgentSecretDialog.tsx b/desktop/src/features/profile/ui/UserProfileCreatedAgentSecretDialog.tsx new file mode 100644 index 000000000..05c66ab12 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileCreatedAgentSecretDialog.tsx @@ -0,0 +1,22 @@ +import { SecretRevealDialog } from "@/features/agents/ui/SecretRevealDialog"; +import type { CreateManagedAgentResponse } from "@/shared/api/types"; +import React from "react"; + +export function useCreatedAgentSecretReveal() { + const [createdAgent, setCreatedAgent] = + React.useState(null); + + return { + createdAgentSecretDialog: createdAgent ? ( + { + if (!open) { + setCreatedAgent(null); + } + }} + /> + ) : null, + setCreatedAgent, + }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 335202c22..98bad227a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,19 +1,47 @@ import * as React from "react"; -import { ArrowLeft, X } from "lucide-react"; +import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useAgentMemoryQuery, useIsManagedAgent, } 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 +52,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"; @@ -35,100 +83,30 @@ import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth"; import { AuxiliaryPanelHeader, - AuxiliaryPanelHeaderGroup, - AuxiliaryPanelTitle, auxiliaryPanelContentPaddingClass, } from "@/shared/layout/AuxiliaryPanelHeader"; import { cn } from "@/shared/lib/cn"; -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; +import type { + AgentPersona, + AcpRuntime, + Channel, + CreateManagedAgentInput, + CreatePersonaInput, + UpdatePersonaInput, +} from "@/shared/api/types"; import { OverlayPanelBackdrop, PANEL_BASE_CLASS, PANEL_OVERLAY_CLASS, PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; +import { + UserProfilePanelHeaderActions, + UserProfilePanelHeaderLeft, +} from "./UserProfilePanelHeaderControls"; +import { useCreatedAgentSecretReveal } from "./UserProfileCreatedAgentSecretDialog"; -type UserProfilePanelProps = { - canResetWidth?: boolean; - currentPubkey?: string; - isSinglePanelView?: boolean; - layout?: "standalone" | "split"; - onClose: () => void; - onOpenDm?: (pubkeys: string[]) => void; - onOpenProfile?: (pubkey: string) => void; - onResetWidth?: () => void; - onResizeStart?: (event: React.PointerEvent) => void; - onViewChange: ( - view: ProfilePanelView, - options?: { replace?: boolean }, - ) => void; - pubkey: string; - /** - * When true, the panel sits beside a sibling pane managed by a single-panel - * width controller (ChannelScreen). The width is clamped so the sibling keeps - * at least THREAD_PANEL_MIN_WIDTH_PX. Standalone/floating mounts (e.g. Pulse) - * have no such sibling, so they omit this and use the configured width - * directly — otherwise `calc(100% - 300px)` would wrongly shrink the panel. - */ - splitPaneClamp?: boolean; - view: ProfilePanelView; - widthPx: number; -}; - -export type ProfilePanelView = "summary" | "memories" | "channels"; - -const VIEW_TITLES: Record = { - summary: "Profile", - memories: "Memories", - channels: "Channels", -}; - -function truncatePubkey(pubkey: string) { - if (pubkey.length <= 16) { - return pubkey; - } - - return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; -} - -type ProfileChannelLink = { - id: string; - name: string; -}; - -function deriveProfileChannels( - pubkeyLower: string, - relayAgent: RelayAgent | undefined, - managedAgent: ManagedAgent | undefined, - channels: Channel[] | undefined, -): ProfileChannelLink[] { - const links = new Map(); - const channelsByName = new Map( - channels?.map((channel) => [channel.name, channel]) ?? [], - ); - - relayAgent?.channels.forEach((name, index) => { - const channel = channelsByName.get(name); - const id = relayAgent.channelIds[index] ?? channel?.id ?? name; - links.set(id, { id, name }); - }); - - if (managedAgent && channels) { - for (const channel of channels) { - const isMember = channel.memberPubkeys.some( - (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, - ); - if (isMember) { - links.set(channel.id, { id: channel.id, name: channel.name }); - } - } - } - - return [...links.values()].sort((left, right) => - left.name.localeCompare(right.name), - ); -} +export type { ProfilePanelView }; export function UserProfilePanel({ canResetWidth, @@ -141,9 +119,10 @@ export function UserProfilePanel({ onResetWidth, onResizeStart, onViewChange, + persona, pubkey, splitPaneClamp = false, - view, + view: controlledView, widthPx, }: UserProfilePanelProps) { const isOverlay = useIsThreadPanelOverlay(); @@ -151,75 +130,131 @@ export function UserProfilePanel({ const isSplitLayout = layout === "split"; useEscapeKey(onClose, isOverlay || isSinglePanelView); + const [internalView, setInternalView] = + React.useState("summary"); + const { createdAgentSecretDialog, setCreatedAgent } = + useCreatedAgentSecretReveal(); + 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(() => { + if (!pubkey) { + return undefined; + } + const agents = managedAgentsQuery.data ?? []; + const pubkeyLower = pubkey.toLowerCase(); + return agents.find((agent) => agent.pubkey.toLowerCase() === pubkeyLower); + }, [managedAgentsQuery.data, pubkey]); + const resolvedPersonaFromSource = React.useMemo(() => { + const personaId = persona?.id ?? managedAgent?.personaId; + if (personaId) { + const refreshedPersona = personasQuery.data?.find( + (candidate) => candidate.id === personaId, + ); + if (refreshedPersona) { + return refreshedPersona; + } + } + if (persona) { + return persona; + } + if (!managedAgent?.personaId) { + return undefined; + } + return personasQuery.data?.find( + (candidate) => candidate.id === managedAgent.personaId, + ); + }, [managedAgent?.personaId, persona, personasQuery.data]); + const profileIdentityKey = + pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`; + const resolvedPersona = useRetainedPersona( + resolvedPersonaFromSource, + profileIdentityKey, + ); + const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null; + const pubkeyLower = effectivePubkey?.toLowerCase() ?? ""; + const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined); const currentProfileQuery = useProfileQuery(currentPubkey !== undefined); - - // Batch avatar prefetch seeds kind:0 summaries without `about`; refetch on open - // so the hero can show the full profile description from relay. React.useEffect(() => { + if (!effectivePubkey) return; void profileQuery.refetch(); - }, [profileQuery.refetch]); - + }, [effectivePubkey, profileQuery.refetch]); const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); - const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + const availableRuntimesQuery = useAvailableAcpRuntimes(); + const acpRuntimesQuery = useAcpRuntimesQuery(); + const createAgentMutation = useCreateManagedAgentMutation(); + const updateManagedAgentMutation = useUpdateManagedAgentMutation(); + const startAgentMutation = useStartManagedAgentMutation(); + const stopAgentMutation = useStopManagedAgentMutation(); + const deleteAgentMutation = useDeleteManagedAgentMutation(); + const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); + const createPersonaMutation = useCreatePersonaMutation(); + const updatePersonaMutation = useUpdatePersonaMutation(); + const deletePersonaMutation = useDeletePersonaMutation(); + const setPersonaActiveMutation = useSetPersonaActiveMutation(); + const exportPersonaJsonMutation = useExportPersonaJsonMutation(); const channelsQuery = useChannelsQuery(); - const presenceQuery = usePresenceQuery([pubkey]); - const userStatusQuery = useUserStatusQuery([pubkey]); + const presenceQuery = usePresenceQuery( + effectivePubkey ? [effectivePubkey] : [], + ); + const userStatusQuery = useUserStatusQuery( + effectivePubkey ? [effectivePubkey] : [], + ); const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); const { onOpenAgentSession } = useAgentSession(); const { goChannel } = useAppNavigation(); - - const profile = profileQuery.data; - const ownerPubkey = profile?.ownerPubkey ?? null; - const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); - const pubkeyLower = pubkey.toLowerCase(); - const presenceStatus = presenceQuery.data?.[pubkeyLower]; - const userStatus = userStatusQuery.data?.[pubkeyLower]; + const profile = resolvePanelProfile({ + managedAgent, + persona: resolvedPersona, + profile: profileQuery.data, + }); + const presenceStatus = pubkeyLower + ? presenceQuery.data?.[pubkeyLower] + : undefined; + const userStatus = pubkeyLower + ? userStatusQuery.data?.[pubkeyLower] + : undefined; const relayAgent = relayAgentsQuery.data?.find( (agent) => agent.pubkey.toLowerCase() === pubkeyLower, ); - const managedAgent = managedAgentsQuery.data?.find( - (agent) => agent.pubkey.toLowerCase() === pubkeyLower, + const managedAgentLogQuery = useManagedAgentLogQuery( + view === "logs" && managedAgent?.backend.type === "local" + ? managedAgent.pubkey + : null, ); - const isBot = Boolean(relayAgent || managedAgent); - // Does THIS desktop hold the agent's seckey? Gates edit (which needs the key) - // and grants owner access when the agent is managed locally. - const isOwner = useIsManagedAgent(isBot ? pubkey : null); - // Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This - // is the right signal for viewing owner-scoped data (activity feed, memory): - // the relay routes and the client decrypts those frames with the owner's OWN - // key, so the agent's seckey is never needed. Computed here (before the gates - // that consume it) so visibility keys off declared ownership, not key custody. + const isBot = Boolean(relayAgent || managedAgent || resolvedPersona); + const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null); + const isOwner = resolvedPersona ? true : managedAgentOwner; + const ownerPubkey = profile?.ownerPubkey ?? null; + const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); const isCurrentUserOwner = currentPubkey !== undefined && ownerPubkey !== null && ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(); - // The viewer may see owner-scoped data if they declared-own the agent OR they - // manage it locally (older agents may not advertise an owner pubkey). Every - // real boundary is server-side, so this only controls what UI we paint. const viewerIsOwner = isCurrentUserOwner || isOwner === true; - // Populate the active-turns store for this agent so useActiveAgentTurns works - // even if the Agents page hasn't been visited yet. - const bridgeAgents = React.useMemo( - () => - managedAgent - ? [{ pubkey: managedAgent.pubkey, status: managedAgent.status }] - : [], - [managedAgent], - ); - // The observer bridge subscribes on the OWNER's own pubkey and decrypts the - // agent's telemetry with the owner's key — no agent seckey needed. It only - // decrypts frames whose agent pubkey is "known", and only subscribes when an - // agent is running/deployed. For a remote agent we own but don't manage - // locally, `managedAgent` is undefined, so we seed the bridge from the relay - // agent (treated as "deployed") when the viewer is the declared owner. This - // mirrors what the composer-area ingress already does in ChannelScreen. + // Populate the observer and active-turn stores for this agent so profile + // activity works even if the Agents page hasn't been visited yet. const observerBridgeAgents = React.useMemo(() => { if (managedAgent) { return [{ pubkey: managedAgent.pubkey, status: managedAgent.status }]; @@ -228,28 +263,52 @@ export function UserProfilePanel({ return [ { pubkey: relayAgent.pubkey, - status: "deployed" as ManagedAgent["status"], + status: "deployed" as const, }, ]; } return []; }, [managedAgent, relayAgent, viewerIsOwner]); - useActiveAgentTurnsBridge(bridgeAgents); + useActiveAgentTurnsBridge(observerBridgeAgents); useManagedAgentObserverBridge(observerBridgeAgents); - const canEditAgent = isOwner === true && managedAgent !== undefined; - const memoryQuery = useAgentMemoryQuery(pubkey, { - enabled: viewerIsOwner, + const canEditAgent = + isOwner === true && + (managedAgent !== undefined || + (resolvedPersona !== undefined && !resolvedPersona.isBuiltIn)); + const memoryQuery = useAgentMemoryQuery(effectivePubkey, { + enabled: viewerIsOwner && Boolean(effectivePubkey), }); const isSelf = - currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); - const canViewActivity = viewerIsOwner && Boolean(onOpenAgentSession); + currentPubkey !== undefined && + pubkeyLower.length > 0 && + pubkeyLower === currentPubkey.toLowerCase(); + const canViewActivity = + viewerIsOwner && Boolean(onOpenAgentSession) && Boolean(effectivePubkey); + const canOpenAgentLogs = + isOwner === true && managedAgent?.backend.type === "local"; + const canInstantiateAgent = + isOwner === true && + resolvedPersona !== undefined && + managedAgent === undefined; + const isAgentActionPending = + createAgentMutation.isPending || + updateManagedAgentMutation.isPending || + startAgentMutation.isPending || + stopAgentMutation.isPending || + deleteAgentMutation.isPending || + startOnLaunchMutation.isPending || + createPersonaMutation.isPending || + updatePersonaMutation.isPending || + deletePersonaMutation.isPending || + setPersonaActiveMutation.isPending || + exportPersonaJsonMutation.isPending; const isFollowing = !isSelf && + pubkeyLower.length > 0 && (contactListQuery.data?.contacts.some( (contact) => contact.pubkey.toLowerCase() === pubkeyLower, ) ?? false); - const profileChannels = React.useMemo( () => deriveProfileChannels( @@ -260,7 +319,6 @@ export function UserProfilePanel({ ), [pubkeyLower, relayAgent, managedAgent, channelsQuery.data], ); - const channelIdToName = React.useMemo(() => { const map: Record = {}; for (const channel of channelsQuery.data ?? []) { @@ -268,167 +326,543 @@ 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(() => { - setEditAgentOpen(true); - }, []); + if (managedAgent) { + setEditAgentOpen(true); + } else if (resolvedPersona && !resolvedPersona.isBuiltIn) { + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + } + }, [managedAgent, 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 runtimeCatalogData = availableRuntimesQuery.isLoading + ? await availableRuntimesQuery.refetch() + : { data: availableRuntimesQuery.data }; + const runtimes = (runtimeCatalogData.data ?? []).filter( + (candidate): candidate is AcpRuntime => + candidate.availability === "available", + ); + const defaultRuntime = runtimes[0] ?? null; + const { runtime, warnings, isOverridden } = resolvePersonaRuntime( + personaToStart.runtime, + runtimes, + defaultRuntime, + ); + for (const warning of warnings) { + toast.warning(warning); + } - const handleOpenActivity = React.useCallback(() => { - onClose(); - onOpenAgentSession?.(pubkey); - }, [onClose, onOpenAgentSession, pubkey]); + if (!runtime) { + throw new Error("No available runtime found for this agent."); + } - const handleOpenChannel = React.useCallback( - (channelId: string) => { - void goChannel(channelId); + const input: CreateManagedAgentInput = { + name: personaToStart.displayName, + acpCommand: "buzz-acp", + agentCommand: runtime.command, + harnessOverride: isOverridden, + agentArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand ?? "", + personaId: personaToStart.id, + systemPrompt: personaToStart.systemPrompt, + avatarUrl: personaToStart.avatarUrl ?? undefined, + model: personaToStart.model ?? undefined, + envVars: personaToStart.envVars, + spawnAfterCreate: true, + startOnAppLaunch: true, + backend: { type: "local" }, + }; + + const created = await createAgentMutation.mutateAsync(input); + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + return created; }, - [goChannel], + [ + availableRuntimesQuery.data, + availableRuntimesQuery.isLoading, + availableRuntimesQuery.refetch, + createAgentMutation.mutateAsync, + managedAgentsQuery.refetch, + relayAgentsQuery.refetch, + ], ); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); - const ownerHandle = React.useMemo(() => { - if (ownerPubkey) { - const ownerProfile = ownerProfileQuery.data; - return ( - ownerProfile?.nip05Handle?.trim() || - ownerProfile?.displayName?.trim() || - truncatePubkey(ownerPubkey) + const handleAgentPrimaryAction = React.useCallback(async () => { + if (!managedAgent) return; + + try { + if (isManagedAgentActive(managedAgent)) { + const result = await stopManagedAgentWithRules({ + agent: managedAgent, + channels: channelsQuery.data ?? [], + relayAgents: relayAgentsQuery.data ?? [], + stopManagedAgent: stopAgentMutation.mutateAsync, + }); + toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`); + return; + } + + await startManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent: startAgentMutation.mutateAsync, + }); + toast.success( + managedAgent.backend.type === "provider" + ? `Deploying ${managedAgent.name}.` + : `Started ${managedAgent.name}.`, + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent action failed.", ); } + }, [ + channelsQuery.data, + managedAgent, + relayAgentsQuery.data, + startAgentMutation.mutateAsync, + stopAgentMutation.mutateAsync, + ]); + + const handleInstantiateAgent = React.useCallback(async () => { + if (!resolvedPersona) return; - if (currentPubkey === undefined || isOwner !== true) { - return null; + try { + const created = await createManagedAgentForPersona(resolvedPersona); + setCreatedAgent(created); + 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, setCreatedAgent]); + + const handleToggleAgentAutoStart = React.useCallback(async () => { + if (managedAgent?.backend.type !== "local") return; + + try { + const updated = await startOnLaunchMutation.mutateAsync({ + pubkey: managedAgent.pubkey, + startOnAppLaunch: !managedAgent.startOnAppLaunch, + }); + toast.success( + updated.startOnAppLaunch + ? `Will start ${updated.name} automatically.` + : `${updated.name} will stay manual-start only.`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update startup preference.", + ); } + }, [managedAgent, startOnLaunchMutation.mutateAsync]); - const currentProfile = currentProfileQuery.data; - return ( - currentProfile?.nip05Handle?.trim() || - currentProfile?.displayName?.trim() || - truncatePubkey(currentPubkey) - ); + const handleDeleteAgent = React.useCallback(async () => { + if (!managedAgent) return; + + try { + const result = await deleteManagedAgentRecord(managedAgent); + if (result.cancelled) return; + + toast.success(`Deleted ${managedAgent.name}.`); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + }, [deleteManagedAgentRecord, managedAgent, onClose]); + + const handleSubmitPersona = React.useCallback( + async (input: CreatePersonaInput | UpdatePersonaInput) => { + await submitProfilePersonaDialog({ + createManagedAgentForPersona, + createPersona: createPersonaMutation.mutateAsync, + input, + managedAgent, + onCreatedAgent: setCreatedAgent, + onDone: () => { + setPersonaDialogState(null); + void personasQuery.refetch(); + }, + previousPersona: resolvedPersona, + runtimes: acpRuntimesQuery.data ?? [], + updateManagedAgent: updateManagedAgentMutation.mutateAsync, + updatePersona: updatePersonaMutation.mutateAsync, + }); + }, + [ + createPersonaMutation.mutateAsync, + createManagedAgentForPersona, + managedAgent, + setCreatedAgent, + personasQuery.refetch, + resolvedPersona, + acpRuntimesQuery.data, + updateManagedAgentMutation.mutateAsync, + updatePersonaMutation.mutateAsync, + ], + ); + + const handleEditPersona = React.useCallback(() => { + if (!resolvedPersona || resolvedPersona.isBuiltIn) return; + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + }, [resolvedPersona]); + + const handleDuplicatePersona = React.useCallback(() => { + if (!resolvedPersona) return; + setPersonaDialogState(duplicatePersonaDialogState(resolvedPersona)); + }, [resolvedPersona]); + + const handleExportPersona = React.useCallback(() => { + if (!resolvedPersona) return; + exportPersonaJsonMutation.mutate(resolvedPersona.id, { + onSuccess: (saved) => { + if (saved) { + toast.success(`Exported ${resolvedPersona.displayName}.`); + } + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to export agent.", + ); + }, + }); + }, [exportPersonaJsonMutation, resolvedPersona]); + + const handleDeletePersona = React.useCallback(async () => { + if (!resolvedPersona) return; + + if (resolvedPersona.isBuiltIn) { + try { + const deletedInstances = + await deleteManagedAgentsForPersona(resolvedPersona); + if (deletedInstances.cancelled) return; + + await setPersonaActiveMutation.mutateAsync({ + id: resolvedPersona.id, + active: false, + }); + toast.success(`Removed ${resolvedPersona.displayName} from My Agents.`); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + return; + } + + if (resolvedPersona.sourceTeam) { + toast.error("This agent is managed by a team."); + return; + } + + setPersonaToDelete(resolvedPersona); }, [ - currentProfileQuery.data, - currentPubkey, - isOwner, - ownerProfileQuery.data, - ownerPubkey, + deleteManagedAgentsForPersona, + onClose, + resolvedPersona, + setPersonaActiveMutation.mutateAsync, ]); + + const handleConfirmDeletePersona = React.useCallback( + async (personaToConfirm: AgentPersona) => { + if (personaToConfirm.sourceTeam) { + toast.error("This agent is managed by a team."); + setPersonaToDelete(null); + return; + } + + try { + 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 persona.", + ); + } + }, + [deletePersonaMutation.mutateAsync, onClose], + ); + + const handleAddedToChannel = React.useCallback( + (channel: Channel, result: AttachManagedAgentToChannelResult) => { + if (result.restarted) { + toast.success( + `Added ${result.agent.name} to ${channel.name} and restarted it.`, + ); + } else if (result.started) { + toast.success(`Added ${result.agent.name} to ${channel.name}.`); + } else if (result.membershipAdded) { + toast.success(`Added ${result.agent.name} to ${channel.name}.`); + } else { + toast.success(`${result.agent.name} is already in ${channel.name}.`); + } + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + void channelsQuery.refetch(); + }, + [ + channelsQuery.refetch, + managedAgentsQuery.refetch, + relayAgentsQuery.refetch, + ], + ); + + const handleOpenActivity = React.useCallback(() => { + if (!effectivePubkey) return; + onClose(); + onOpenAgentSession?.(effectivePubkey); + }, [effectivePubkey, onClose, onOpenAgentSession]); + const handleOpenChannel = React.useCallback( + (channelId: string) => { + void goChannel(channelId); + }, + [goChannel], + ); + const displayName = resolveProfileDisplayName({ + persona: resolvedPersona, + profile, + pubkey: effectivePubkey, + }); + const ownerProfile = ownerPubkey + ? ownerProfileQuery.data + : isOwner === true + ? currentProfileQuery.data + : undefined; + const ownerHandle = resolveOwnerHandle( + ownerProfile, + ownerPubkey ?? (isOwner === true ? currentPubkey : undefined), + ); const ownerDisplayName = ownerHandle ? isCurrentUserOwner || (!ownerPubkey && isOwner === true) ? `${ownerHandle} (you)` : ownerHandle : null; - const panelTitle = VIEW_TITLES[view]; - const memoryCount = memoryQuery.data - ? (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length - : undefined; + const memoryCount = + memoryQuery.data && + (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length; + const agentInstruction = resolveAgentInstruction( + managedAgent, + resolvedPersona, + ); + const canManagePersona = isOwner === true && resolvedPersona !== undefined; + const canEditPersona = + canManagePersona && resolvedPersona?.isBuiltIn !== true; + const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; + const { + agentInfoFields, + agentSettingsFields, + diagnosticsFields, + diagnosticsSummary, + modelLabel, + } = useProfileFieldBuckets({ + isBot, + isOwner, + managedAgent, + ownerAvatarUrl: ownerProfileQuery.data?.avatarUrl ?? null, + ownerDisplayName, + ownerHandle, + ownerPubkey, + onOpenOwner: + ownerPubkey && onOpenProfile + ? () => onOpenProfile(ownerPubkey) + : undefined, + persona: resolvedPersona, + presenceLoaded: presenceQuery.isSuccess, + presenceStatus, + profile, + pubkey: effectivePubkey, + relayAgent, + }); const headerLeftContent = ( - - {view !== "summary" ? ( - - ) : null} - {panelTitle} - + setView("summary")} + /> ); - const headerActions = ( -
- {view === "memories" && viewerIsOwner ? ( - - ) : null} - -
+ ); - const profileBody = (
{view === "summary" ? ( onViewChange("channels")} - onOpenOwner={ - ownerPubkey && onOpenProfile - ? () => onOpenProfile(ownerPubkey) - : undefined - } - onOpenMemories={() => onViewChange("memories")} + agentInfoFields={agentInfoFields} + agentSettingsFields={agentSettingsFields} + diagnosticsFields={diagnosticsFields} + diagnosticsSummary={diagnosticsSummary} + modelLabel={modelLabel} + onOpenAgentInfo={() => setView("info")} + onOpenAgentSettings={() => setView("settings")} + onOpenChannels={() => setView("channels")} + onOpenDiagnostics={() => setView("diagnostics")} + onOpenInstruction={() => setView("instructions")} + onOpenMemories={() => setView("memories")} + onOpenModel={() => setView("model")} onOpenDm={onOpenDm} - presenceLoaded={presenceQuery.isSuccess} + persona={resolvedPersona} presenceStatus={presenceStatus} profile={profile} - pubkey={pubkey} + pubkey={effectivePubkey} relayAgent={relayAgent} unfollowMutation={unfollowMutation} userStatus={userStatus} /> ) : null} - {view === "memories" ? ( - + {view === "memories" && effectivePubkey ? ( + + ) : null} + + {view === "instructions" ? ( + + ) : null} + + {view === "info" ? ( + + ) : null} + {view === "model" ? ( + void managedAgentsQuery.refetch()} + /> + ) : null} + + {view === "settings" ? ( + + ) : null} + + {view === "diagnostics" ? ( + setView("logs")} + pubkey={effectivePubkey} + /> ) : null} {view === "channels" ? ( setAddToChannelOpen(true)} onOpenChannel={handleOpenChannel} /> ) : null} + + {view === "logs" ? ( + + ) : null}
); @@ -440,7 +874,44 @@ export function UserProfilePanel({ open={editAgentOpen} /> ) : null; - + const addAgentToChannelDialog = managedAgent ? ( + + ) : null; + const personaDialogs = ( + setPersonaToDelete(null)} + onCloseDialog={() => setPersonaDialogState(null)} + onConfirmDelete={(selectedPersona) => { + void handleConfirmDeletePersona(selectedPersona); + }} + onSubmit={handleSubmitPersona} + /> + ); if (isSplitLayout) { return ( <> @@ -452,6 +923,9 @@ export function UserProfilePanel({ {profileBody} {editAgentDialog} + {addAgentToChannelDialog} + {createdAgentSecretDialog} + {personaDialogs} ); } @@ -495,7 +969,7 @@ export function UserProfilePanel({ {!isOverlay ? ( + ); +} + +function ProfilePersonaPrimaryActions({ + canEditAgent, + disabled, + onEditAgent, + onStartAgent, +}: { + canEditAgent: boolean; + disabled: boolean; + onEditAgent: () => void; + onStartAgent: () => void; +}) { + return ( +
+ + {canEditAgent ? ( + + ) : null}
); } @@ -530,343 +675,17 @@ function ProfileQuickAction({ ); } -// ── Field rows ─────────────────────────────────────────────────────────────── - -type ProfileField = { - copyValue?: string; - /** - * Plain-text representation. Always required so non-visual surfaces (e.g. tooltips, - * copy-to-clipboard) keep working. When `displayNode` is set, the row renders that - * instead of the text — but the text still drives the title/tooltip. - */ - displayValue: string; - /** - * Optional rich rendering for the value cell (e.g. a status badge). When present, - * replaces the plain text node in the row. - */ - displayNode?: React.ReactNode; - icon: LucideIcon; - label: string; - testId?: string; -}; - -function buildPublicFields({ - isBot, - profile, - pubkey, - relayAgent, -}: { - isBot: boolean; - profile: ProfileSummaryViewProps["profile"]; - pubkey: string; - relayAgent: RelayAgent | undefined; -}): ProfileField[] { - const fields: ProfileField[] = [ - { - copyValue: pubkey, - displayValue: truncatePubkeyShort(pubkey), - icon: Fingerprint, - label: "Public key", - testId: "user-profile-copy-pubkey", - }, - ]; - - if (profile?.nip05Handle) { - fields.push({ - copyValue: profile.nip05Handle, - displayValue: profile.nip05Handle, - icon: UserRound, - label: "NIP-05", - testId: "user-profile-nip05", - }); - } - - if (isBot && relayAgent?.agentType) { - fields.push({ - copyValue: relayAgent.agentType, - displayValue: runtimeLabel(relayAgent.agentType), - icon: Cpu, - label: "Agent type", - testId: "user-profile-agent-type", - }); - } - - if (relayAgent?.capabilities.length) { - fields.push({ - copyValue: relayAgent.capabilities.join(", "), - displayValue: relayAgent.capabilities.join(", "), - icon: Server, - label: "Capabilities", - testId: "user-profile-capabilities", - }); - } - - return fields; -} - -function buildOwnerFields({ - includeOperationalFields, - managedAgent, - ownerDisplayName, - ownerAvatarUrl, - ownerHandle, - ownerPubkey, - onOpenOwner, - presenceLoaded, - presenceStatus, - relayAgent, -}: { - includeOperationalFields: boolean; - managedAgent: ManagedAgent | undefined; - ownerDisplayName: string | null; - ownerAvatarUrl: string | null; - ownerHandle: string | null; - ownerPubkey: string | null; - onOpenOwner?: () => void; - presenceLoaded: boolean; - presenceStatus: "online" | "away" | "offline" | undefined; - relayAgent: RelayAgent | undefined; -}): ProfileField[] { - const fields: ProfileField[] = []; - - if (ownerDisplayName) { - fields.push({ - copyValue: onOpenOwner - ? undefined - : (ownerPubkey ?? ownerHandle ?? undefined), - displayValue: ownerDisplayName, - displayNode: onOpenOwner ? ( - - ) : undefined, - icon: UserRound, - label: "Owned by", - testId: "user-profile-owned-by", - }); - } - - if (!includeOperationalFields) { - return fields; - } - - if (managedAgent?.agentCommand) { - fields.push({ - copyValue: managedAgent.agentCommand, - displayValue: runtimeLabel(managedAgent.agentCommand), - icon: Terminal, - label: "Runtime", - testId: "user-profile-runtime", - }); - } else if (relayAgent?.agentType) { - fields.push({ - copyValue: relayAgent.agentType, - displayValue: runtimeLabel(relayAgent.agentType), - icon: Terminal, - label: "Runtime", - testId: "user-profile-runtime", - }); - } - - if (managedAgent) { - fields.push({ - displayValue: managedAgent.status - .replace(/_/g, " ") - .replace(/\b\w/g, (char: string) => char.toUpperCase()), - displayNode: ( - - ), - icon: Activity, - label: "Status", - testId: "user-profile-agent-status", - }); - } - - if (managedAgent?.model) { - fields.push({ - copyValue: managedAgent.model, - displayValue: managedAgent.model, - icon: Cpu, - label: "Model", - testId: "user-profile-model", - }); - } - - if (managedAgent?.acpCommand) { - fields.push({ - copyValue: managedAgent.acpCommand, - displayValue: managedAgent.acpCommand, - icon: Terminal, - label: "ACP command", - testId: "user-profile-acp", - }); - } - - if (managedAgent?.mcpCommand) { - fields.push({ - copyValue: managedAgent.mcpCommand, - displayValue: managedAgent.mcpCommand, - icon: Terminal, - label: "MCP command", - testId: "user-profile-mcp", - }); - } - - if (managedAgent?.backend.type === "provider") { - const backendLabel = managedAgent.backend.id; - fields.push({ - copyValue: backendLabel, - displayValue: backendLabel, - icon: Server, - label: "Backend", - testId: "user-profile-backend", - }); - } - - if (managedAgent) { - fields.push({ - displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No", - icon: Server, - label: "Start on launch", - testId: "user-profile-start-on-launch", - }); - fields.push({ - displayValue: managedAgent.respondTo.replace(/-/g, " "), - icon: MessageSquare, - label: "Respond to", - testId: "user-profile-respond-to", - }); - } - - if (managedAgent?.lastError) { - fields.push({ - copyValue: managedAgent.lastError, - displayValue: managedAgent.lastError, - icon: Activity, - label: "Last error", - testId: "user-profile-last-error", - }); - } - - return fields; -} - -function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { - const publicKeyLabel = "Public key"; - const ownedByLabel = "Owned by"; - const statusLabel = "Status"; - const orderedFields = [ - ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === ownedByLabel), - ...fields.filter( - (field) => - field.label !== publicKeyLabel && - field.label !== ownedByLabel && - field.copyValue, - ), - ...fields.filter((field) => field.label === statusLabel), - ...fields.filter((field) => { - if ( - field.label === publicKeyLabel || - field.label === ownedByLabel || - field.label === statusLabel - ) { - return false; - } - return !field.copyValue; - }), - ]; - - return ( -
-
- {orderedFields.map((field) => ( - - ))} -
-
- ); -} - -function ProfileFieldRow({ field }: { field: ProfileField }) { - const Icon = field.icon; - const isCopyable = Boolean(field.copyValue); - - const content = ( - <> - - - - - - {field.label} - - - {field.displayNode ?? field.displayValue} - - - {isCopyable ? ( - - ) : null} - - ); - - if (isCopyable && field.copyValue) { - return ( - - ); - } - - return ( -
- {content} -
- ); -} - // ── Ingress rows ───────────────────────────────────────────────────────────── function ProfileIngressRow({ + disabled, icon: Icon, label, onClick, testId, trailing, }: { + disabled?: boolean; icon: LucideIcon; label: string; onClick: () => void; @@ -875,8 +694,9 @@ function ProfileIngressRow({ }) { return ( @@ -898,18 +723,18 @@ function ProfileIngressRow({ export function MemoryFocusedView({ agentPubkey, - viewerIsOwner, + isOwner, }: { agentPubkey: string; - viewerIsOwner: boolean | undefined; + isOwner: boolean | undefined; }) { - if (viewerIsOwner !== true) { + if (isOwner !== true) { return null; } return (
- +
); } @@ -920,55 +745,246 @@ type ProfileChannelLink = { }; export function ChannelsFocusedView({ + canAddToChannel, channels, + isActionPending, isLoading, + onAddToChannel, onOpenChannel, }: { + canAddToChannel: boolean; channels: ProfileChannelLink[]; + isActionPending: boolean; isLoading: boolean; + onAddToChannel: () => void; onOpenChannel: (channelId: string) => void; }) { - if (isLoading) { - return ( -

- Loading channels… -

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

+ Loading channels… +

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

+ No visible channel memberships. +

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

- No visible channel memberships. -

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

+ No instruction set. +

+ )} + + {onEdit ? ( + + ) : null} + ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs new file mode 100644 index 000000000..8ace8999b --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -0,0 +1,233 @@ +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", + 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({ envVars: { SHARED: "old", AGENT_ONLY: "keep" } }), + persona({ + runtime: "claude", + envVars: { SHARED: "new", PERSONA_ONLY: "set" }, + }), + { + previousPersona: persona({ + runtime: "goose", + envVars: { SHARED: "old" }, + }), + runtimes: [runtime()], + }, + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { SHARED: "new", PERSONA_ONLY: "set", AGENT_ONLY: "keep" }, + agentCommand: "claude", + agentArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + }, + ); +}); + +test("personaManagedAgentUpdate preserves agent env overrides when persona env is unchanged", () => { + assert.deepEqual( + personaManagedAgentUpdate( + agent({ + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { API_KEY: "agent-secret" }, + }), + persona({ envVars: { API_KEY: "persona-default" } }), + { + previousPersona: persona({ envVars: { API_KEY: "persona-default" } }), + }, + ), + null, + ); +}); + +test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => { + assert.equal( + personaManagedAgentUpdate( + 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("personaManagedAgentUpdate leaves runtime inheritance when persona runtime is cleared", () => { + assert.deepEqual( + personaManagedAgentUpdate( + agent({ + agentCommand: "claude", + agentArgs: ["mcp", "serve"], + mcpCommand: "claude-mcp", + }), + persona({ runtime: null }), + { + previousPersona: persona({ runtime: "claude" }), + runtimes: [ + runtime({ + id: "goose", + command: "goose", + defaultArgs: [], + mcpCommand: "", + }), + runtime({ id: "claude" }), + ], + }, + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + }, + ); +}); + +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..c4d36d490 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -0,0 +1,361 @@ +import * as React from "react"; +import type { + AcpRuntimeCatalogEntry, + AgentPersona, + Channel, + ManagedAgent, + Profile, + RelayAgent, + UpdateManagedAgentInput, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type ProfileChannelLink = { + id: string; + name: string; +}; + +export type ProfilePanelView = + | "summary" + | "info" + | "settings" + | "diagnostics" + | "model" + | "instructions" + | "memories" + | "channels" + | "logs"; + +export const PROFILE_PANEL_VIEW_TITLES: Record = { + summary: "Profile", + info: "Agent info", + settings: "Agent settings", + diagnostics: "Diagnostics", + model: "Model", + instructions: "Agent instruction", + memories: "Memories", + channels: "Channels", + logs: "Harness log", +}; + +const PROFILE_PANEL_VIEWS = new Set( + Object.keys(PROFILE_PANEL_VIEW_TITLES) as ProfilePanelView[], +); + +export function parseProfilePanelView(value: unknown): ProfilePanelView | null { + return typeof value === "string" && + PROFILE_PANEL_VIEWS.has(value as ProfilePanelView) + ? (value as ProfilePanelView) + : null; +} + +export function profilePanelViewFromSearch(value: unknown): ProfilePanelView { + return parseProfilePanelView(value) ?? "summary"; +} + +export type UserProfilePanelProps = { + canResetWidth?: boolean; + currentPubkey?: string; + isSinglePanelView?: boolean; + layout?: "standalone" | "split"; + onClose: () => void; + onOpenDm?: (pubkeys: string[]) => void; + onOpenProfile?: (pubkey: string) => void; + onResetWidth?: () => void; + onResizeStart?: (event: React.PointerEvent) => void; + onViewChange?: ( + view: ProfilePanelView, + options?: { replace?: boolean }, + ) => void; + persona?: AgentPersona; + pubkey?: string; + splitPaneClamp?: boolean; + view?: ProfilePanelView; + widthPx: number; +}; + +export function truncatePubkey(pubkey: string) { + if (pubkey.length <= 16) { + return pubkey; + } + + return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; +} + +export function deriveProfileChannels( + pubkeyLower: string, + relayAgent: RelayAgent | undefined, + managedAgent: ManagedAgent | undefined, + channels: Channel[] | undefined, +): ProfileChannelLink[] { + const links = new Map(); + const channelsByName = new Map( + channels?.map((channel) => [channel.name, channel]) ?? [], + ); + + relayAgent?.channels.forEach((name, index) => { + const channel = channelsByName.get(name); + const id = relayAgent.channelIds[index] ?? channel?.id ?? name; + links.set(id, { id, name }); + }); + + if (managedAgent && channels) { + for (const channel of channels) { + const isMember = channel.memberPubkeys.some( + (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, + ); + if (isMember) { + links.set(channel.id, { id: channel.id, name: channel.name }); + } + } + } + + return [...links.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); +} + +export function getRelayAgentChannelIds( + relayAgents: readonly RelayAgent[] | undefined, + agentPubkey: string, +): string[] { + const normalized = normalizePubkey(agentPubkey); + const agent = (relayAgents ?? []).find( + (candidate) => normalizePubkey(candidate.pubkey) === normalized, + ); + return agent?.channelIds ?? []; +} + +export function buildPersonaDraftProfile(persona: AgentPersona): Profile { + return { + pubkey: "", + displayName: persona.displayName, + avatarUrl: persona.avatarUrl, + about: null, + nip05Handle: null, + ownerPubkey: null, + }; +} + +export function resolvePanelProfile({ + persona, + profile, +}: { + managedAgent: ManagedAgent | undefined; + persona: AgentPersona | undefined; + profile: Profile | undefined; +}): Profile | undefined { + const baseProfile = + profile ?? (persona ? buildPersonaDraftProfile(persona) : undefined); + return withProfileAvatarFallback(baseProfile, [persona?.avatarUrl]); +} + +export function resolveProfileAvatarUrl( + ...candidates: Array +): string | null { + for (const candidate of candidates) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} + +export function withProfileAvatarFallback( + profile: Profile | undefined, + fallbackAvatarUrls: Array, +): Profile | undefined { + const profileAvatarUrl = normalizeProfileFallbackAvatarUrl( + profile?.avatarUrl, + ); + const avatarUrl = resolveProfileAvatarUrl( + profileAvatarUrl, + ...fallbackAvatarUrls.map((avatarUrl) => + normalizeProfileFallbackAvatarUrl(avatarUrl), + ), + ); + return profile && avatarUrl !== profile.avatarUrl + ? { ...profile, avatarUrl } + : profile; +} + +function normalizeProfileFallbackAvatarUrl( + avatarUrl: string | null | undefined, +): string | null { + const trimmed = avatarUrl?.trim(); + if (!trimmed) return null; + return trimmed; +} + +export function resolveProfileDisplayName({ + persona, + profile, + pubkey, +}: { + persona: AgentPersona | undefined; + profile: Profile | undefined; + pubkey: string | null; +}) { + return ( + profile?.displayName ?? + persona?.displayName ?? + (pubkey ? truncatePubkey(pubkey) : "Agent") + ); +} + +export function resolveOwnerHandle( + profile: Profile | undefined, + currentPubkey: string | undefined, +) { + if (currentPubkey === undefined) { + return null; + } + + return ( + profile?.nip05Handle?.trim() || + profile?.displayName?.trim() || + truncatePubkey(currentPubkey) + ); +} + +export function resolveAgentInstruction( + managedAgent: ManagedAgent | undefined, + persona: AgentPersona | undefined, +) { + return ( + managedAgent?.systemPrompt?.trim() || persona?.systemPrompt.trim() || null + ); +} + +export function personaManagedAgentUpdate( + agent: ManagedAgent, + persona: AgentPersona, + options: { + previousPersona?: AgentPersona; + runtimes?: readonly AcpRuntimeCatalogEntry[]; + } = {}, +): UpdateManagedAgentInput | null { + if (agent.personaId !== persona.id) return null; + + const input: UpdateManagedAgentInput = { pubkey: agent.pubkey }; + let hasChanges = false; + + if (persona.displayName !== agent.name) { + input.name = persona.displayName; + hasChanges = true; + } + + if (persona.systemPrompt !== (agent.systemPrompt ?? "")) { + input.systemPrompt = persona.systemPrompt; + hasChanges = true; + } + + if ((persona.model ?? null) !== (agent.model ?? null)) { + input.model = persona.model; + hasChanges = true; + } + + const nextEnvVars = mergedPersonaEnvVarsForAgent( + agent, + persona, + options.previousPersona, + ); + if (!stringRecordEqual(nextEnvVars, agent.envVars)) { + input.envVars = nextEnvVars; + hasChanges = true; + } + + const runtimeChanged = + options.previousPersona !== undefined && + options.previousPersona.runtime !== persona.runtime; + const runtime = runtimeChanged + ? resolvePersonaManagedAgentRuntime(persona.runtime, options.runtimes) + : 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 resolvePersonaManagedAgentRuntime( + runtimeId: string | null | undefined, + runtimes: readonly AcpRuntimeCatalogEntry[] | undefined, +) { + if (!runtimes?.length) return undefined; + if (!runtimeId) return undefined; + return runtimes.find((candidate) => candidate.id === runtimeId); +} + +function mergedPersonaEnvVarsForAgent( + agent: ManagedAgent, + persona: AgentPersona, + previousPersona: AgentPersona | undefined, +) { + if (!previousPersona) { + return persona.envVars; + } + if (stringRecordEqual(persona.envVars, previousPersona.envVars)) { + return agent.envVars; + } + + const nextEnvVars = { ...persona.envVars }; + for (const [key, value] of Object.entries(agent.envVars)) { + if (previousPersona.envVars[key] !== value) { + nextEnvVars[key] = value; + } + } + return nextEnvVars; +} + +function stringArrayEqual(left: readonly string[], right: readonly string[]) { + if (left.length !== right.length) return false; + + 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/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 1baef3b14..1ed60c0c9 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -3,9 +3,10 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { + profilePanelViewFromSearch, type ProfilePanelView, - UserProfilePanel, -} from "@/features/profile/ui/UserProfilePanel"; +} from "@/features/profile/ui/UserProfilePanelUtils"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { PulseView } from "@/features/pulse/ui/PulseView"; import { useIdentityQuery } from "@/shared/api/hooks"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; @@ -18,10 +19,7 @@ export function PulseScreen() { const identityQuery = useIdentityQuery(); const { applyPatch, values } = useHistorySearchState(PULSE_PANEL_SEARCH_KEYS); const profilePanelPubkey = values.profile; - const profilePanelView: ProfilePanelView = - values.profileView === "memories" || values.profileView === "channels" - ? values.profileView - : "summary"; + const profilePanelView = profilePanelViewFromSearch(values.profileView); const handleOpenProfilePanel = React.useCallback( (pubkey: string) => applyPatch({ profile: pubkey, profileView: null }), [applyPatch], 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 ( diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c969bd2a0..25ec481fc 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1034,7 +1034,7 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText("deadbeef"); + await expect(panel).toContainText("npub1mock..."); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index 05ba8e0f2..2f3385ba3 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -64,15 +64,22 @@ async function setMesh( }, mesh); } -async function openManagedAgentActions( +async function openManagedAgentProfile( page: import("@playwright/test").Page, pubkey: string, ) { - const trigger = page.getByTestId(`managed-agent-actions-${pubkey}`); - await trigger.scrollIntoViewIfNeeded(); - await trigger.focus(); - await trigger.press("Enter"); - await expect(trigger).toHaveAttribute("data-state", "open"); + const row = page.getByTestId(`managed-agent-${pubkey}`); + await row.getByRole("button", { name: "Manage" }).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); +} + +async function clickManagedAgentPrimaryAction( + page: import("@playwright/test").Page, + label: string, +) { + const action = page.getByTestId("user-profile-agent-primary-action"); + await expect(action).toContainText(label); + await action.click(); } async function openNewAgentMenu(page: import("@playwright/test").Page) { @@ -330,8 +337,8 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a 0, ); - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Stop" }).click(); + await openManagedAgentProfile(page, pubkey); + await clickManagedAgentPrimaryAction(page, "Stop"); await expect .poll(async () => await commands(page)) .toContain("stop_managed_agent"); @@ -339,22 +346,19 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a // With a live serve target for the model, manual restart goes through: // the backend preflight re-resolves the target and the agent starts. - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Spawn" }).click(); + await clickManagedAgentPrimaryAction(page, "Respawn"); await expect .poll(async () => await commands(page)) .toContain("start_managed_agent"); await expect(row).toContainText("running"); - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Stop" }).click(); + await clickManagedAgentPrimaryAction(page, "Stop"); await expect(row).toContainText("stopped"); // Without a live serve target, the backend preflight rejects the start // with an actionable error, surfaced as a toast; the agent stays stopped. await setMesh(page, { models: [] }); - await openManagedAgentActions(page, pubkey); - await page.getByRole("menuitem", { name: "Spawn" }).click(); + await clickManagedAgentPrimaryAction(page, "Respawn"); await expect( page