Files
buzz/desktop/src/features/profile/ui/UserProfilePanel.tsx
Will Pfleger 83aab8cb5c feat(desktop): remove communities locally and stop their agent pairs (#7997)
🤖 Leave contacts the relay before it removes anything locally: it checks
the relay's `/info` for NIP-43 membership support, then waits for the
relay to accept a signed leave request. When a relay has been torn down,
that check fails, Leave throws, and the community stays in the rail.
Removing a community also left its relay's managed agent pairs running,
and they kept reconnecting to it until the app was quit.

**Remove from this device**
- A new community action, **Remove from this device**, sits under
**Leave community** in the profile menu's community actions. It never
contacts the relay. A confirm dialog explains that relay membership
isn't revoked.
- `useCommunityNavigationTransitions` splits the old `removeCommunity`
into `removeCommunityFromDevice` (local-only cleanup and navigation to a
fallback community or the discovery screen) and
`leaveAndRemoveCommunity` (calls `leaveCommunity`, then runs the same
cleanup). Leave's flow hasn't changed.
- When Leave fails, the error now adds: "If the community no longer
exists, use Remove from this device." The error shown when community
discovery state can't be saved no longer says "Membership was removed",
so its wording fits both actions.

**Agent pairs on a removed relay**
- Once the local removal completes, the shared cleanup stops every live
agent pair on that relay, including manually started ones. It matches
relays with `canonicalRelayUrl`. Stopping is best-effort: failures are
logged and the removal still completes. Pairs on other relays aren't
touched, and nothing is stopped if another community still uses the same
relay.
- Both reconcile call sites (`useManagedAgentRuntimeReconciliation` and
`bootstrapManagedAgentRuntimePairs`) now go through
`reconcileConfiguredManagedAgentRuntimes`. A reconcile that is already
running in Rust can't be cancelled, so after it resolves, this helper
stops any pair it started whose relay is no longer in the saved
community list.
- Launch restore runs in the background after `apply_workspace` and can
finish after its community is removed. `apply_workspace` takes a restore
ticket (the current `launch_restore_generation` in `AppState`) before
handing its lock to the restore task. Removing the active community
calls the new `invalidate_launch_restore` command, which bumps that
generation while holding the runtime transition lock.
`restore_managed_agents_on_launch` re-checks its ticket under the same
lock and spawns nothing if it changed. If restore takes the lock first,
its pairs are registered before invalidation returns, and the removal
stop then finds them. Removal never changes `relay_url_override`: the
outgoing relay stays applied until the next `apply_workspace`, exactly
as on `main`, so in-flight work never falls back to the default relay.
Invalidation never takes `workspace_apply_lock`, which restore holds
while waiting on the transition lock, so lock order is unchanged.
- With no relay applied, backend readers fall back to the default relay,
the same state as launch before the first `apply_workspace`. A normal
quit and relaunch doesn't bring a pair back on a removed relay: restore
targets the applied workspace, and the legacy per-record relay pin is
ignored.

**Tests**
- A `community-rail` e2e case makes the mocked relay's membership check
fail, the way an unreachable relay does. It checks that Leave shows the
error and keeps the community, and that Remove from this device then
removes it, switches to the fallback, stops the agent pair on the
removed relay, and leaves the pair on the other relay running.
- Unit tests in `managedAgentRelayCleanup.test.mjs` cover relay
matching, a stop or listing failure not throwing, and the reconcile
fence stopping a pair that started on a relay removed mid-flight.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 18:43:21 +00:00

1002 lines
34 KiB
TypeScript

import * as React from "react";
import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import {
type AttachManagedAgentToChannelResult,
useAcpRuntimesQuery,
useAvailableAcpRuntimes,
useCreateManagedAgentMutation,
useCreatePersonaMutation,
useDeleteManagedAgentMutation,
useDeletePersonaMutation,
useManagedAgentLogQuery,
useRelayAgentsQuery,
useManagedAgentsQuery,
usePersonasQuery,
useSetManagedAgentStartOnAppLaunchMutation,
useSetPersonaActiveMutation,
useStartManagedAgentMutation,
useStopManagedAgentMutation,
useUpdateManagedAgentMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog";
import {
availableRuntimesForStart,
buildInstanceInputForDefinition,
resolveStartRuntimeForDefinition,
} from "@/features/agents/lib/instanceInputForDefinition";
import { describeLogFile } from "@/features/agents/ui/agentUi";
import { useAgentLifecycleActions } from "@/features/profile/ui/useAgentLifecycleActions";
import {
duplicatePersonaDialogState,
editPersonaDialogState,
type PersonaDialogState,
} from "@/features/agents/ui/personaDialogState";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useIdentityArchive } from "@/features/identity-archive/hooks";
import { useAgentAvailabilityLookup } from "@/features/agents/lib/useAgentAvailability";
import {
useContactListQuery,
useFollowMutation,
useProfileQuery,
useUnfollowMutation,
useUserProfileQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent";
import { useCanonicalManagedAgentProfile } from "@/features/profile/lib/useCanonicalManagedAgentProfile";
import {
AgentInstructionsFocusedView,
ProfileSummaryView,
} from "@/features/profile/ui/UserProfilePanelSections";
import {
AgentInfoFocusedView,
ChannelsFocusedView,
DiagnosticsFocusedView,
MemoryFocusedView,
} from "@/features/profile/ui/UserProfilePanelFocusedViews";
import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails";
import { UserProfileAgentSettingsMenuSlot } 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";
import {
useCardMint,
UserProfilePersonaDialogs,
} from "@/features/profile/ui/UserProfilePersonaDialogs";
import {
deriveProfileChannels,
type ProfilePanelTab,
type ProfilePanelView,
profilePanelTargetKey,
resolveAgentInstruction,
resolvePanelProfile,
resolveProfileDisplayName,
truncateNpub,
type UserProfilePanelProps,
useRetainedPersona,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions";
import { useUserStatusQuery } from "@/features/user-status/hooks";
import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type {
AgentPersona,
Channel,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame";
import { getUserProfilePanelHeaderContent } from "@/features/profile/ui/UserProfilePanelHeaderContent";
import { UserProfileEditAgentDialog } from "@/features/profile/ui/UserProfileEditAgentDialog";
import { useProfileEditAgentRequest } from "@/features/profile/ui/useProfileEditAgentRequest";
export type { ProfilePanelTab, ProfilePanelView };
export function UserProfilePanel({
canResetWidth,
currentPubkey,
isSinglePanelView = false,
layout = "standalone",
onClose,
onOpenDm,
onOpenProfile,
onResetWidth,
onResizeStart,
onTabChange,
onViewChange,
persona,
pubkey,
splitPaneClamp = false,
tab: controlledTab,
view: controlledView,
widthPx,
transparentChrome = false,
}: UserProfilePanelProps) {
const { globalConfig } = useGlobalAgentConfig();
const isOverlay = useIsThreadPanelOverlay();
const isSplitLayout = layout === "split";
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const [internalView, setInternalView] =
React.useState<ProfilePanelView>("summary");
const view = controlledView ?? internalView;
const setView = React.useCallback(
(nextView: ProfilePanelView, options?: { replace?: boolean }) => {
if (onViewChange) {
onViewChange(nextView, options);
return;
}
setInternalView(nextView);
},
[onViewChange],
);
const [internalTab, setInternalTab] = React.useState<ProfilePanelTab>("info");
const tab = controlledTab ?? internalTab;
const setTab = React.useCallback(
(nextTab: ProfilePanelTab, options?: { replace?: boolean }) => {
if (onTabChange) {
onTabChange(nextTab, options);
return;
}
setInternalTab(nextTab);
},
[onTabChange],
);
const [stickyChrome, setStickyChrome] = React.useState({
active: false,
height: 0,
});
const handleStickyChromeChange = React.useCallback(
(nextState: { active: boolean; height: number }) => {
setStickyChrome((currentState) =>
currentState.active === nextState.active &&
currentState.height === nextState.height
? currentState
: nextState,
);
},
[],
);
const {
focus: editAgentFocus,
open: editAgentOpen,
setFocus: setEditAgentFocus,
setOpen: setEditAgentOpen,
} = useProfileEditAgentRequest(pubkey);
const [addToChannelOpen, setAddToChannelOpen] = React.useState(false);
const [personaDialogState, setPersonaDialogState] =
React.useState<PersonaDialogState | null>(null);
const [personaToDelete, setPersonaToDelete] =
React.useState<AgentPersona | null>(null);
const [personaToExportSnapshot, setPersonaToExportSnapshot] =
React.useState<AgentPersona | null>(null);
const [requestedInstancePubkey, setRequestedInstancePubkey] = React.useState<
string | null
>(null);
const preserveRequestedInstance = Boolean(
pubkey &&
requestedInstancePubkey &&
normalizePubkey(pubkey) === normalizePubkey(requestedInstancePubkey),
);
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
const { instanceBuckets, linkedPersonaId, managedAgent } =
useCanonicalManagedAgentProfile({
managedAgents: managedAgentsQuery.data,
personaId: persona?.id,
pubkey,
});
const resolvedPersonaFromSource = React.useMemo(() => {
if (pubkey && !managedAgent) return undefined;
const personaId = linkedPersonaId ?? managedAgent?.personaId;
if (personaId) {
const refreshedPersona = personasQuery.data?.find(
(candidate) => candidate.id === personaId,
);
if (refreshedPersona) {
return refreshedPersona;
}
}
if (!pubkey && persona) {
return persona;
}
if (!managedAgent?.personaId) {
return undefined;
}
return personasQuery.data?.find(
(candidate) => candidate.id === managedAgent.personaId,
);
}, [linkedPersonaId, managedAgent, persona, personasQuery.data, pubkey]);
const profileIdentityKey =
pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`;
const retainedPersona = useRetainedPersona(
resolvedPersonaFromSource,
profileIdentityKey,
);
const resolvedPersona = pubkey && !managedAgent ? undefined : retainedPersona;
const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null;
const pubkeyLower = effectivePubkey?.toLowerCase() ?? "";
const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined);
const currentProfileQuery = useProfileQuery(currentPubkey !== undefined);
React.useEffect(() => {
if (!effectivePubkey) return;
void profileQuery.refetch();
}, [effectivePubkey, profileQuery.refetch]);
const relayAgentsQuery = useRelayAgentsQuery({ enabled: true });
const availableRuntimesQuery = useAvailableAcpRuntimes();
const acpRuntimesQuery = useAcpRuntimesQuery();
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 usersBatchQuery = useUsersBatchQuery(
effectivePubkey ? [effectivePubkey] : [],
);
const channelsQuery = useChannelsQuery();
const { query: presenceQuery, getAvailability } = useAgentAvailabilityLookup(
effectivePubkey ? [effectivePubkey] : [],
);
const presenceStatus = getAvailability(effectivePubkey);
const userStatusQuery = useUserStatusQuery(
effectivePubkey ? [effectivePubkey] : [],
);
const contactListQuery = useContactListQuery(currentPubkey);
const followMutation = useFollowMutation(currentPubkey);
const unfollowMutation = useUnfollowMutation(currentPubkey);
const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity();
const { goChannel } = useAppNavigation();
const profile = resolvePanelProfile({
managedAgent,
persona: resolvedPersona,
profile: profileQuery.data,
});
const ownerPubkey = profile?.ownerPubkey ?? null;
const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined);
const userStatus = pubkeyLower
? userStatusQuery.data?.[pubkeyLower]
: undefined;
const relayAgent = relayAgentsQuery.data?.find(
(agent) => agent.pubkey.toLowerCase() === pubkeyLower,
);
const managedAgentLogQuery = useManagedAgentLogQuery(
(view === "diagnostics" || view === "logs") &&
managedAgent?.backend.type === "local"
? managedAgent.pubkey
: null,
);
const isAgentByOaOwner = Boolean(
usersBatchQuery.data?.profiles[pubkeyLower]?.isAgent,
);
const isBot =
Boolean(relayAgent || managedAgent || resolvedPersona) || isAgentByOaOwner;
const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null);
// Does THIS desktop hold the agent's seckey (or is this an editable persona)?
// Gates edit (which needs the key) and grants owner access when managed locally.
const isOwner = resolvedPersona ? true : managedAgentOwner;
// 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 isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey);
// 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;
const activityAgent = React.useMemo(
() =>
resolveProfileActivityAgent({
effectivePubkey,
isBot,
managedAgent,
profile: profile ?? null,
relayAgent,
viewerIsOwner,
}),
[effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner],
);
// Observer ingestion is owner-global across local and declared-owned agents.
const canEditAgent = Boolean(isOwner && (managedAgent ?? resolvedPersona));
const isSelf =
currentPubkey !== undefined &&
pubkeyLower.length > 0 &&
pubkeyLower === currentPubkey.toLowerCase();
const canViewActivity =
viewerIsOwner &&
Boolean(effectivePubkey) &&
canOpenAgentActivity(effectivePubkey);
const canOpenAgentLogs =
isOwner === true && managedAgent?.backend.type === "local";
const canInstantiateAgent =
!pubkey &&
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;
const isFollowing =
!isSelf &&
pubkeyLower.length > 0 &&
(contactListQuery.data?.contacts.some(
(contact) => contact.pubkey.toLowerCase() === pubkeyLower,
) ??
false);
const profileChannels = React.useMemo(
() =>
deriveProfileChannels(
pubkeyLower,
relayAgent,
managedAgent,
channelsQuery.data,
),
[pubkeyLower, relayAgent, managedAgent, channelsQuery.data],
);
const channelIdToName = React.useMemo(() => {
const map: Record<string, string> = {};
for (const channel of channelsQuery.data ?? []) {
map[channel.id] = channel.name;
}
return map;
}, [channelsQuery.data]);
const targetKey = profilePanelTargetKey(pubkey, persona?.id);
const prevTargetKeyRef = React.useRef(targetKey);
React.useEffect(() => {
if (prevTargetKeyRef.current === targetKey) return;
prevTargetKeyRef.current = targetKey;
if (preserveRequestedInstance) return;
setRequestedInstancePubkey(null);
setView("summary", { replace: true });
setTab("info", { replace: true });
}, [preserveRequestedInstance, setTab, setView, targetKey]);
const {
canHuddle,
canMessage,
canWave,
handleHuddle,
handleMessage,
handleWave,
isStartingHuddle,
pendingAction,
} = useProfileInteractionActions({
effectivePubkey,
enabled: onOpenDm !== undefined,
isBot,
isSelf,
onClose,
viewerIsOwner,
});
const openResolvedPersonaEditor = React.useCallback(() => {
if (!resolvedPersona) return false;
setPersonaDialogState(
editPersonaDialogState(resolvedPersona, managedAgent),
);
return true;
}, [managedAgent, resolvedPersona]);
const handleEditAgent = React.useCallback(() => {
if (openResolvedPersonaEditor()) return;
setEditAgentOpen(true);
}, [openResolvedPersonaEditor, setEditAgentOpen]);
const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } =
useProfileAgentDeletion({
channels: channelsQuery.data,
deleteManagedAgent: deleteAgentMutation.mutateAsync,
managedAgent,
managedAgents: managedAgentsQuery.data,
getAvailability,
relayAgents: relayAgentsQuery.data,
});
const createManagedAgentForPersona = React.useCallback(
async (personaToStart: AgentPersona) => {
const runtimes = await availableRuntimesForStart(availableRuntimesQuery);
const { runtime, warnings } = resolveStartRuntimeForDefinition(
personaToStart,
runtimes,
globalConfig.preferred_runtime,
);
for (const warning of warnings) {
toast.warning(warning);
}
const input = await buildInstanceInputForDefinition(
personaToStart,
runtime,
);
const created = await createAgentMutation.mutateAsync(input);
void managedAgentsQuery.refetch();
void relayAgentsQuery.refetch();
return created;
},
[
availableRuntimesQuery,
createAgentMutation.mutateAsync,
globalConfig.preferred_runtime,
managedAgentsQuery.refetch,
relayAgentsQuery.refetch,
],
);
const { handleAgentPrimaryAction, handleAgentRestart } =
useAgentLifecycleActions({
availability: presenceStatus,
channels: channelsQuery.data,
managedAgent,
relayAgents: relayAgentsQuery.data,
startManagedAgent: startAgentMutation.mutateAsync,
stopManagedAgent: stopAgentMutation.mutateAsync,
});
const handleInstantiateAgent = React.useCallback(async () => {
if (!resolvedPersona) return;
try {
const created = await createManagedAgentForPersona(resolvedPersona);
if (isRelayRemovedError(created.spawnError)) {
// Its community was removed meanwhile: created, not started, no error.
} else 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 = openResolvedPersonaEditor;
const handleDuplicatePersona = React.useCallback(() => {
if (!resolvedPersona) return;
setPersonaDialogState(duplicatePersonaDialogState(resolvedPersona));
}, [resolvedPersona]);
const handleExportPersona = React.useCallback(() => {
if (resolvedPersona) {
setPersonaToExportSnapshot(resolvedPersona);
}
}, [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 {
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.",
);
}
},
[deletePersonaMutation.mutateAsync, onClose],
);
// Count of managed-agent instances backed by the persona being deleted.
// Shown in the confirm dialog so the user knows what will be cascade-deleted.
const personaDeleteInstanceCount = React.useMemo(
() =>
personaToDelete
? (managedAgentsQuery.data ?? []).filter(
(a) => a.personaId === personaToDelete.id,
).length
: 0,
[managedAgentsQuery.data, personaToDelete],
);
const handleAddedToChannel = React.useCallback(
(channel: Channel, result: AttachManagedAgentToChannelResult) => {
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(
(channelId?: string | null) => {
if (!effectivePubkey) return;
openAgentActivity(effectivePubkey, { channelId: channelId ?? null });
},
[effectivePubkey, openAgentActivity],
);
const handleOpenChannel = React.useCallback(
(channelId: string) => {
void goChannel(channelId);
},
[goChannel],
);
const displayName = resolveProfileDisplayName({
persona: resolvedPersona,
profile,
pubkey: effectivePubkey,
});
const ownerHandle = React.useMemo(() => {
if (ownerPubkey) {
const ownerProfile = ownerProfileQuery.data;
return (
ownerProfile?.nip05Handle?.trim() ||
ownerProfile?.displayName?.trim() ||
truncateNpub(ownerPubkey)
);
}
if (currentPubkey === undefined || isOwner !== true) {
return null;
}
const currentProfile = currentProfileQuery.data;
return (
currentProfile?.nip05Handle?.trim() ||
currentProfile?.displayName?.trim() ||
truncateNpub(currentPubkey)
);
}, [
currentProfileQuery.data,
currentPubkey,
isOwner,
ownerProfileQuery.data,
ownerPubkey,
]);
const ownerDisplayName = ownerHandle
? isCurrentUserOwner || (!ownerPubkey && isOwner === true)
? `${ownerHandle} (you)`
: ownerHandle
: null;
const ownerProfilePubkey =
ownerPubkey ?? (isOwner === true ? (currentPubkey ?? null) : null);
const agentInstruction = resolveAgentInstruction(
managedAgent,
resolvedPersona,
);
const canManagePersona = isOwner === true && resolvedPersona !== undefined;
const cardMint = useCardMint(resolvedPersona, managedAgent);
const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam;
const canDeleteProfileAgent =
isBot &&
((viewerIsOwner && managedAgent !== undefined) ||
(canInstantiateAgent && canDeletePersona));
const handleDeleteProfileAgent =
viewerIsOwner && managedAgent ? handleDeleteAgent : handleDeletePersona;
const archiveActions = useIdentityArchive(effectivePubkey);
const agentSettingsMenu = isBot ? null : (
<UserProfileAgentSettingsMenuSlot
archiveActions={archiveActions}
canInstantiateAgent={canInstantiateAgent}
canManagePersona={canManagePersona}
isAgentActionPending={isAgentActionPending}
isBot={isBot}
managedAgent={managedAgent}
onDuplicatePersona={handleDuplicatePersona}
onExportPersona={handleExportPersona}
onToggleAutoStart={handleToggleAgentAutoStart}
personaActionKey={resolvedPersona?.id}
viewerIsOwner={viewerIsOwner}
/>
);
const { agentInfoFields, agentSettingsFields, diagnosticsFields } =
useProfileFieldBuckets({
isBot,
isOwner: viewerIsOwner,
managedAgent,
onOpenProfile,
ownerDisplayName,
ownerHandle,
ownerProfilePubkey,
ownerPubkey,
persona: resolvedPersona,
presenceLoaded: presenceQuery.isSuccess,
presenceStatus,
profile,
pubkey: effectivePubkey,
relayAgent,
});
const isDiagnosticsLikeView = view === "diagnostics" || view === "logs";
const managedAgentLogContent = managedAgentLogQuery.data?.content ?? null;
const logHeaderSubtitle =
isDiagnosticsLikeView && managedAgent
? `${managedAgent.name} · ${describeLogFile(managedAgent.logPath)}`
: null;
const { headerActions, headerLeftContent } = getUserProfilePanelHeaderContent(
{
agentSettingsMenu,
effectivePubkey,
ownerPubkey: profile?.ownerPubkey,
logCopyValue: isDiagnosticsLikeView ? managedAgentLogContent : null,
logSubtitle: logHeaderSubtitle,
onBack: () => setView("summary"),
onEditAgent: canEditAgent ? handleEditAgent : undefined,
view,
viewerIsOwner,
},
);
const profileBody = (
<AuxiliaryPanelBody
className={cn(
"px-4 pb-6",
isDiagnosticsLikeView
? "flex flex-col overflow-hidden"
: "overflow-y-auto",
)}
data-testid="user-profile-scroll-body"
>
{view === "summary" ? (
<ProfileSummaryView
canAddToChannel={managedAgent !== undefined && isOwner === true}
canDeleteAgent={canDeleteProfileAgent}
canEditAgent={canEditAgent}
canInstantiateAgent={canInstantiateAgent}
canOpenAgentLogs={canOpenAgentLogs}
canViewActivity={canViewActivity}
channelCount={profileChannels.length}
channelIdToName={channelIdToName}
channels={profileChannels}
channelsLoading={channelsQuery.isLoading}
displayName={displayName}
followMutation={followMutation}
agentInstruction={agentInstruction}
handleAgentPrimaryAction={handleAgentPrimaryAction}
handleAgentRestart={handleAgentRestart}
handleEditAgent={handleEditAgent}
handleToggleAgentAutoStart={handleToggleAgentAutoStart}
handleEditPersona={canManagePersona ? handleEditPersona : undefined}
handleHuddle={canHuddle ? handleHuddle : undefined}
handleInstantiateAgent={handleInstantiateAgent}
handleMessage={canMessage ? handleMessage : undefined}
handleWave={canWave ? handleWave : undefined}
isArchived={archiveActions.isArchived === true}
isHuddlePending={pendingAction === "huddle" || isStartingHuddle}
isMessagePending={pendingAction === "message"}
isWavePending={pendingAction === "wave"}
isBot={isBot}
isAgentActionPending={isAgentActionPending}
isFollowing={isFollowing}
isOwner={viewerIsOwner}
isSelf={isSelf}
instanceBuckets={instanceBuckets}
activityAgent={activityAgent}
managedAgent={managedAgent}
agentInfoFields={agentInfoFields}
archiveActions={archiveActions}
agentSettingsFields={agentSettingsFields}
diagnosticsFields={diagnosticsFields}
onAddToChannel={() => setAddToChannelOpen(true)}
onCreateCard={isBot && canManagePersona ? cardMint.create : undefined}
onDeleteAgent={handleDeleteProfileAgent}
onDuplicateAgent={
isBot && canManagePersona ? handleDuplicatePersona : undefined
}
onExportAgent={
isBot && canManagePersona ? handleExportPersona : undefined
}
onOpenInstance={(instancePubkey) => {
setRequestedInstancePubkey(instancePubkey);
onOpenProfile?.(instancePubkey);
setTab("runtime");
}}
onOpenActivity={handleOpenActivity}
onOpenChannel={handleOpenChannel}
onOpenDiagnostics={() => setView("diagnostics")}
onStickyChromeChange={handleStickyChromeChange}
onTabChange={setTab}
presenceStatus={presenceStatus}
profile={profile}
pubkey={effectivePubkey}
relayAgent={relayAgent}
tab={tab}
unfollowMutation={unfollowMutation}
userStatus={userStatus}
/>
) : null}
{view === "memories" && effectivePubkey ? (
<MemoryFocusedView
agentPubkey={effectivePubkey}
viewerIsOwner={viewerIsOwner}
/>
) : null}
{view === "info" ? (
<AgentInfoFocusedView metadataFields={agentInfoFields} />
) : null}
{view === "configuration" ? (
<AgentConfigurationFocusedView fields={agentSettingsFields} />
) : null}
{view === "instructions" ? (
<AgentInstructionsFocusedView instruction={agentInstruction} />
) : null}
{view === "diagnostics" ? (
<DiagnosticsFocusedView
canOpenAgentLogs={canOpenAgentLogs}
fields={diagnosticsFields}
logContent={managedAgentLogContent}
logError={
managedAgentLogQuery.error instanceof Error
? managedAgentLogQuery.error
: null
}
logLoading={managedAgentLogQuery.isLoading}
managedAgent={managedAgent}
/>
) : null}
{view === "channels" ? (
<ChannelsFocusedView
canAddToChannel={managedAgent !== undefined && isOwner === true}
channels={profileChannels}
isActionPending={isAgentActionPending}
isLoading={channelsQuery.isLoading}
onAddToChannel={() => setAddToChannelOpen(true)}
onOpenChannel={handleOpenChannel}
/>
) : null}
{view === "logs" ? (
<DiagnosticsFocusedView
canOpenAgentLogs={canOpenAgentLogs}
fields={[]}
logContent={managedAgentLogContent}
logError={
managedAgentLogQuery.error instanceof Error
? managedAgentLogQuery.error
: null
}
logLoading={managedAgentLogQuery.isLoading}
managedAgent={managedAgent}
/>
) : null}
</AuxiliaryPanelBody>
);
const editAgentDialog = (
<UserProfileEditAgentDialog
agent={managedAgent}
canEdit={canEditAgent}
initialFocus={editAgentFocus}
onEditLinkedPersona={
resolvedPersona && !resolvedPersona.isBuiltIn
? () => {
setEditAgentOpen(false);
setEditAgentFocus(undefined);
openResolvedPersonaEditor();
}
: undefined
}
onOpenChange={(next) => {
setEditAgentOpen(next);
if (!next) setEditAgentFocus(undefined);
}}
open={editAgentOpen}
/>
);
const addAgentToChannelDialog = managedAgent ? (
<AddAgentToChannelDialog
agent={managedAgent ?? null}
onAdded={handleAddedToChannel}
onOpenChange={setAddToChannelOpen}
open={addToChannelOpen}
/>
) : null;
const personaDialogs = (
<>
<UserProfilePersonaDialogs
cardMintTarget={cardMint.target}
createError={
createPersonaMutation.error instanceof Error
? createPersonaMutation.error
: null
}
instanceCount={personaDeleteInstanceCount}
isPending={
createPersonaMutation.isPending ||
updatePersonaMutation.isPending ||
updateManagedAgentMutation.isPending ||
createAgentMutation.isPending
}
linkedAgentPubkey={managedAgent?.pubkey ?? null}
personaDialogState={personaDialogState}
personaToDelete={personaToDelete}
personaToExportSnapshot={personaToExportSnapshot}
resolvedPersona={resolvedPersona}
runtimes={acpRuntimesQuery.data ?? []}
runtimesError={acpRuntimesQuery.isError}
runtimesLoading={acpRuntimesQuery.isLoading}
updateError={
updatePersonaMutation.error instanceof Error
? updatePersonaMutation.error
: null
}
onCloseCardMint={cardMint.close}
onCloseDelete={() => setPersonaToDelete(null)}
onCloseDialog={() => setPersonaDialogState(null)}
onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)}
onConfirmDelete={(selectedPersona) => {
void handleConfirmDeletePersona(selectedPersona);
}}
onExportSnapshot={setPersonaToExportSnapshot}
onSubmit={handleSubmitPersona}
/>
</>
);
return (
<UserProfilePanelFrame
addAgentToChannelDialog={addAgentToChannelDialog}
canResetWidth={canResetWidth}
editAgentDialog={editAgentDialog}
headerActions={headerActions}
headerLeftContent={headerLeftContent}
isOverlay={isOverlay}
isSinglePanelView={isSinglePanelView}
isSplitLayout={isSplitLayout}
onClose={onClose}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
personaDialogs={personaDialogs}
profileBody={profileBody}
splitPaneClamp={splitPaneClamp}
stickyChromeActive={view === "summary" && stickyChrome.active}
stickyChromeEnabled={view === "summary" && isBot}
stickyChromeHeight={stickyChrome.height}
widthPx={widthPx}
transparentChrome={transparentChrome}
/>
);
}