Move agent management into profile sidebar

This commit is contained in:
klopez4212
2026-06-23 17:11:16 +01:00
parent 5306735986
commit f2db5bbb50
16 changed files with 2872 additions and 931 deletions

View File

@@ -6,7 +6,6 @@ export type AgentGroupRowsProps = {
agents: ManagedAgent[];
channelIdToName: Record<string, string>;
channelsByPubkey: Record<string, { id: string; name: string }[]>;
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 (
<div className="divide-y divide-border/50 border-t border-border/50">
@@ -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}
/>
))}
</div>

View File

@@ -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<ProfilePanelTarget | null>(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 (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<AgentsView />
</React.Suspense>
</div>
<ProfilePanelProvider
onOpenPersonaProfilePanel={(persona) =>
setProfilePanelTarget({ kind: "persona", persona })
}
onOpenProfilePanel={(pubkey) =>
setProfilePanelTarget({ kind: "pubkey", pubkey })
}
>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<AgentsView />
</React.Suspense>
{profilePanelTarget ? (
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
currentPubkey={identityQuery.data?.pubkey}
onClose={() => 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}
</div>
</div>
</ProfilePanelProvider>
);
}

View File

@@ -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);
onOpenAgentProfile={(pubkey) => {
openProfilePanel?.(pubkey);
}}
onOpenPersonaProfile={(persona) => {
openPersonaProfilePanel?.(persona);
}}
onSelectLogAgent={agents.setLogAgentPubkey}
onStartAgent={(pubkey) => {
void agents.handleStart(pubkey);
}}
onStopAgent={(pubkey) => {
void agents.handleStop(pubkey);
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
void agents.handleToggleStartOnAppLaunch(
pubkey,
startOnAppLaunch,
);
}}
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);
}}

View File

@@ -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<string, string>;
channelNames: { id: string; name: string }[];
isActionPending: boolean;
isLogSelected: boolean;
logContent: string | null;
logError: Error | null;
@@ -72,14 +44,9 @@ export function ManagedAgentRow({
personaLabelsById: Record<string, string>;
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({
)}
<div className="flex shrink-0 items-start gap-2 lg:pt-0.5">
<ModelPicker agent={agent} />
<AgentActionsMenu
agent={agent}
isActionPending={isActionPending}
isActive={isActive}
onAddToChannel={onAddToChannel}
onDelete={onDelete}
onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)}
onStart={onStart}
onStop={onStop}
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
/>
<Button
onClick={() => onOpenProfile(agent.pubkey)}
size="sm"
type="button"
variant="outline"
>
Manage
</Button>
</div>
</div>
@@ -274,12 +237,6 @@ function AgentSummary({
<span>Remote deployment</span>
)}
</div>
{agent.personaOutOfDate ? (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Persona updated since this agent was created. Respawn to apply the
new configuration.
</p>
) : null}
{channelNames.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{channelNames.map((channel) => (
@@ -297,6 +254,12 @@ function AgentSummary({
))}
</div>
) : null}
{agent.personaOutOfDate ? (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Persona updated since this agent was created. Respawn to apply the
new configuration.
</p>
) : null}
{activeWorkingChannels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{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 (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Agent actions for ${agent.name}`}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
data-testid={`managed-agent-actions-${agent.pubkey}`}
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{agent.backend.type === "provider" ? (
<>
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onStart(agent.pubkey)}
>
<Play className="h-4 w-4" />
{isActive ? "Redeploy" : "Deploy"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onStop(agent.pubkey)}
>
<Square className="h-4 w-4" />
Shutdown
</DropdownMenuItem>
</>
) : isActive ? (
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onStop(agent.pubkey)}
>
<Square className="h-4 w-4" />
Stop
</DropdownMenuItem>
) : (
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onStart(agent.pubkey)}
>
<Play className="h-4 w-4" />
Spawn
</DropdownMenuItem>
)}
{agent.backend.type !== "provider" ? (
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={isActionPending}
onClick={() => onAddToChannel(agent)}
>
<UserPlus className="h-4 w-4" />
Add to channel
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
await navigator.clipboard.writeText(agent.pubkey);
toast.success("Copied pubkey to clipboard");
}}
>
<Clipboard className="h-4 w-4" />
Copy pubkey
</DropdownMenuItem>
{agent.backend.type === "local" ? (
<DropdownMenuItem onClick={() => onOpenLogs(agent.pubkey)}>
<FileText className="h-4 w-4" />
View logs
</DropdownMenuItem>
) : null}
{agent.backend.type === "local" ? (
<DropdownMenuItem
disabled={isActionPending}
onClick={() =>
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
}
>
<Power className="h-4 w-4" />
{agent.startOnAppLaunch
? "Disable auto-start"
: "Enable auto-start"}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isActionPending}
onClick={() => onDelete(agent.pubkey)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{editOpen ? (
<EditAgentDialog
agent={agent}
onOpenChange={setEditOpen}
open={editOpen}
/>
) : null}
</>
);
}
function AgentOriginBadge({ agent }: { agent: ManagedAgent }) {
return (
<Badge variant="outline">

View File

@@ -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<string, string>;
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 ? (
<Badge variant="outline">Inactive</Badge>
) : null}
<PersonaActionsMenu
isActionPending={isActionPending}
isPending={isPersonasPending}
persona={g.persona}
onDuplicate={onDuplicatePersona}
onEdit={onEditPersona}
onExport={onExportPersona}
onDeactivate={onDeactivatePersona}
onDelete={onDeletePersona}
/>
<Button
disabled={isPersonasPending}
onClick={() => onOpenPersonaProfile(g.persona)}
size="sm"
type="button"
variant="ghost"
>
Manage
</Button>
</div>
</div>
{!isCollapsed && hasAgents ? (

View File

@@ -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 (
<section className="space-y-2">
{onDuplicatePersona ? (
<AgentActionRow
disabled={isPending}
icon={CopyPlus}
label="Duplicate"
onClick={onDuplicatePersona}
testId={`user-profile-persona-duplicate-${personaKey}`}
/>
) : null}
{onExportPersona ? (
<AgentActionRow
disabled={isPending}
icon={Download}
label="Export"
onClick={onExportPersona}
testId={`user-profile-persona-export-${personaKey}`}
/>
) : null}
{onDelete ? (
<AgentActionRow
destructive
disabled={isPending}
icon={Trash2}
label="Delete agent"
onClick={onDelete}
testId={`user-profile-agent-delete-${actionKey}`}
/>
) : null}
</section>
);
}
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 (
<button
className={cn(
"flex w-full items-center gap-3 rounded-2xl bg-muted/20 px-4 py-2 text-left transition-colors hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-50",
destructive && "text-destructive hover:bg-destructive/10",
)}
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon
className={cn(
"h-4 w-4",
destructive ? "text-destructive" : "text-muted-foreground",
)}
/>
</span>
<span className="min-w-0 flex-1 text-sm font-medium text-foreground">
{label}
</span>
{trailing ? (
<span className="text-sm capitalize text-muted-foreground">
{trailing}
</span>
) : null}
</button>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
import * as React from "react";
import {
deleteManagedAgentWithRules,
type ManagedAgentActionResult,
} from "@/features/agents/lib/managedAgentControlActions";
import { removeChannelMember } from "@/shared/api/tauri";
import type {
AgentPersona,
Channel,
ManagedAgent,
PresenceLookup,
RelayAgent,
} from "@/shared/api/types";
import { getRelayAgentChannelIds } from "@/features/profile/ui/UserProfilePanelUtils";
type DeleteManagedAgentRulesContext = Omit<
Parameters<typeof deleteManagedAgentWithRules>[0],
"agent"
>;
type DeleteProfileManagedAgentContext = DeleteManagedAgentRulesContext & {
removeAgentFromAllChannels: (pubkey: string) => Promise<void>;
};
type DeleteProfileManagedAgentsForPersonaContext =
DeleteProfileManagedAgentContext & {
managedAgents: readonly ManagedAgent[];
selectedAgent?: ManagedAgent;
};
type UseProfileAgentDeletionInput = {
channels?: readonly Channel[];
deleteManagedAgent: DeleteManagedAgentRulesContext["deleteManagedAgent"];
managedAgent?: ManagedAgent;
managedAgents?: readonly ManagedAgent[];
presenceLookup?: PresenceLookup | null;
relayAgents?: readonly RelayAgent[];
};
export function useProfileAgentDeletion({
channels,
deleteManagedAgent,
managedAgent,
managedAgents,
presenceLookup,
relayAgents,
}: UseProfileAgentDeletionInput) {
const removeAgentFromAllChannels = React.useCallback(
async (agentPubkey: string) => {
const normalizedPubkey = agentPubkey.toLowerCase();
const channelIds = new Set(
getRelayAgentChannelIds(relayAgents, agentPubkey),
);
for (const channel of channels ?? []) {
if (
channel.memberPubkeys.some(
(memberPubkey) => memberPubkey.toLowerCase() === normalizedPubkey,
)
) {
channelIds.add(channel.id);
}
}
if (channelIds.size === 0) return;
await Promise.allSettled(
[...channelIds].map((channelId) =>
removeChannelMember(channelId, agentPubkey),
),
);
},
[channels, relayAgents],
);
const deleteManagedAgentRecord = React.useCallback(
(agentToDelete: ManagedAgent) =>
deleteProfileManagedAgent(agentToDelete, {
channels: channels ?? [],
deleteManagedAgent,
presenceLookup,
relayAgents: relayAgents ?? [],
removeAgentFromAllChannels,
}),
[
channels,
deleteManagedAgent,
presenceLookup,
relayAgents,
removeAgentFromAllChannels,
],
);
const deleteManagedAgentsForPersona = React.useCallback(
(persona: AgentPersona) =>
deleteProfileManagedAgentsForPersona(persona, {
channels: channels ?? [],
deleteManagedAgent,
managedAgents: managedAgents ?? [],
presenceLookup,
relayAgents: relayAgents ?? [],
removeAgentFromAllChannels,
selectedAgent: managedAgent,
}),
[
channels,
deleteManagedAgent,
managedAgent,
managedAgents,
presenceLookup,
relayAgents,
removeAgentFromAllChannels,
],
);
return {
deleteManagedAgentRecord,
deleteManagedAgentsForPersona,
removeAgentFromAllChannels,
};
}
export async function deleteProfileManagedAgent(
agent: ManagedAgent,
context: DeleteProfileManagedAgentContext,
): Promise<ManagedAgentActionResult> {
const { removeAgentFromAllChannels, ...deleteContext } = context;
const result = await deleteManagedAgentWithRules({
agent,
...deleteContext,
});
if (result.cancelled) return result;
await removeAgentFromAllChannels(agent.pubkey);
return result;
}
export async function deleteProfileManagedAgentsForPersona(
persona: AgentPersona,
context: DeleteProfileManagedAgentsForPersonaContext,
): Promise<ManagedAgentActionResult> {
const { managedAgents, selectedAgent, ...deleteContext } = context;
const agentsByPubkey = new Map<string, ManagedAgent>();
for (const agent of managedAgents) {
if (agent.personaId === persona.id) {
agentsByPubkey.set(agent.pubkey, agent);
}
}
if (selectedAgent?.personaId === persona.id) {
agentsByPubkey.set(selectedAgent.pubkey, selectedAgent);
}
for (const agent of agentsByPubkey.values()) {
const result = await deleteProfileManagedAgent(agent, deleteContext);
if (result.cancelled) return result;
}
return {};
}

View File

@@ -0,0 +1,491 @@
import type { LucideIcon } from "lucide-react";
import {
Activity,
Copy,
Cpu,
Fingerprint,
MessageSquare,
Server,
Terminal,
UserRound,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity";
import type {
AgentPersona,
ManagedAgent,
Profile,
RelayAgent,
} from "@/shared/api/types";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const RUNTIME_LABELS: Record<string, string> = {
goose: "Goose",
"claude-code": "Claude Code",
"codex-acp": "Codex",
aider: "Aider",
};
function runtimeLabel(command: string): string {
return RUNTIME_LABELS[command] ?? command;
}
async function copyToClipboard(value: string, label?: string) {
await navigator.clipboard.writeText(value);
toast.success(label ? `Copied ${label}` : "Copied to clipboard");
}
export type ProfileField = {
copyValue?: string;
displayValue: string;
displayNode?: React.ReactNode;
icon: LucideIcon;
label: string;
testId?: string;
};
const AGENT_INFO_LABELS = new Set([
"Public key",
"Owned by",
"NIP-05",
"Agent type",
"Capabilities",
"Backend",
]);
const AGENT_SETTINGS_LABELS = new Set([
"Runtime",
"Respond to",
"ACP command",
"MCP command",
]);
const DIAGNOSTICS_LABELS = new Set(["Status", "Last error"]);
export function bucketProfileFields(fields: ProfileField[]) {
return {
agentInfoFields: fields.filter((field) =>
AGENT_INFO_LABELS.has(field.label),
),
agentSettingsFields: fields.filter((field) =>
AGENT_SETTINGS_LABELS.has(field.label),
),
diagnosticsFields: fields.filter((field) =>
DIAGNOSTICS_LABELS.has(field.label),
),
};
}
export function useProfileFieldBuckets({
isBot,
isOwner,
managedAgent,
ownerAvatarUrl,
ownerDisplayName,
ownerHandle,
ownerPubkey,
onOpenOwner,
persona,
presenceLoaded,
presenceStatus,
profile,
pubkey,
relayAgent,
}: {
isBot: boolean;
isOwner: boolean | undefined;
managedAgent: ManagedAgent | undefined;
ownerAvatarUrl: string | null;
ownerDisplayName: string | null;
ownerHandle: string | null;
ownerPubkey: string | null;
onOpenOwner?: () => void;
persona: AgentPersona | undefined;
presenceLoaded: boolean;
presenceStatus: "online" | "away" | "offline" | undefined;
profile: Profile | undefined;
pubkey: string | null;
relayAgent: RelayAgent | undefined;
}) {
return React.useMemo(() => {
const metadataFields = [
...buildPublicFields({ pubkey, profile, relayAgent, isBot, persona }),
...(isOwner === true
? buildOwnerFields({
managedAgent,
ownerAvatarUrl,
ownerDisplayName,
ownerHandle,
ownerPubkey,
onOpenOwner,
persona,
presenceLoaded,
presenceStatus,
relayAgent,
})
: []),
];
const diagnosticsFields =
bucketProfileFields(metadataFields).diagnosticsFields;
return {
...bucketProfileFields(metadataFields),
diagnosticsSummary:
diagnosticsFields.find((field) => field.label === "Status")
?.displayValue ??
diagnosticsFields.find((field) => field.label === "Last error")
?.displayValue ??
null,
modelLabel: managedAgent?.model ?? persona?.model ?? "Auto",
};
}, [
isBot,
isOwner,
managedAgent,
ownerAvatarUrl,
ownerDisplayName,
ownerHandle,
ownerPubkey,
onOpenOwner,
persona,
presenceLoaded,
presenceStatus,
profile,
pubkey,
relayAgent,
]);
}
export function buildPublicFields({
isBot,
persona,
profile,
pubkey,
relayAgent,
}: {
isBot: boolean;
persona?: AgentPersona;
profile: Profile | undefined;
pubkey: string | null;
relayAgent: RelayAgent | undefined;
}): ProfileField[] {
const fields: ProfileField[] = [];
if (pubkey) {
fields.push({
copyValue: pubkey,
displayValue: truncatePubkeyShort(pubkey),
icon: Fingerprint,
label: "Public key",
testId: "user-profile-copy-pubkey",
});
}
if (profile?.nip05Handle) {
fields.push({
copyValue: profile.nip05Handle,
displayValue: profile.nip05Handle,
icon: UserRound,
label: "NIP-05",
testId: "user-profile-nip05",
});
}
if (isBot && relayAgent?.agentType) {
fields.push({
copyValue: relayAgent.agentType,
displayValue: runtimeLabel(relayAgent.agentType),
icon: Cpu,
label: "Agent type",
testId: "user-profile-agent-type",
});
}
if (!pubkey && persona) {
fields.push({
displayValue: "Not deployed",
icon: Activity,
label: "Status",
testId: "user-profile-agent-status",
});
}
if (relayAgent?.capabilities.length) {
fields.push({
copyValue: relayAgent.capabilities.join(", "),
displayValue: relayAgent.capabilities.join(", "),
icon: Server,
label: "Capabilities",
testId: "user-profile-capabilities",
});
}
return fields;
}
export function buildOwnerFields({
managedAgent,
ownerAvatarUrl,
ownerDisplayName,
ownerHandle,
ownerPubkey,
onOpenOwner,
persona,
presenceLoaded,
presenceStatus,
relayAgent,
}: {
managedAgent: ManagedAgent | undefined;
ownerAvatarUrl: string | null;
ownerDisplayName: string | null;
ownerHandle: string | null;
ownerPubkey: string | null;
onOpenOwner?: () => void;
persona?: AgentPersona;
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 ? (
<button
className="inline-flex max-w-full items-center gap-2 rounded text-left text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.stopPropagation();
onOpenOwner();
}}
title={ownerDisplayName}
type="button"
>
<UserAvatar
avatarUrl={ownerAvatarUrl}
className="shrink-0"
displayName={ownerHandle ?? ownerDisplayName}
size="xs"
testId="user-profile-owner-avatar"
/>
<span className="truncate">{ownerDisplayName}</span>
</button>
) : undefined,
icon: UserRound,
label: "Owned by",
testId: "user-profile-owned-by",
});
}
if (managedAgent?.agentCommand) {
fields.push({
copyValue: managedAgent.agentCommand,
displayValue: runtimeLabel(managedAgent.agentCommand),
icon: Terminal,
label: "Runtime",
testId: "user-profile-runtime",
});
} else if (relayAgent?.agentType) {
fields.push({
copyValue: relayAgent.agentType,
displayValue: runtimeLabel(relayAgent.agentType),
icon: Terminal,
label: "Runtime",
testId: "user-profile-runtime",
});
} else if (persona?.runtime) {
fields.push({
copyValue: persona.runtime,
displayValue: runtimeLabel(persona.runtime),
icon: Terminal,
label: "Runtime",
testId: "user-profile-runtime",
});
}
if (managedAgent) {
fields.push({
displayValue: managedAgent.status
.replace(/_/g, " ")
.replace(/\b\w/g, (char: string) => char.toUpperCase()),
displayNode: (
<AgentStatusBadge
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={managedAgent.status}
/>
),
icon: Activity,
label: "Status",
testId: "user-profile-agent-status",
});
}
if (managedAgent?.model) {
fields.push({
copyValue: managedAgent.model,
displayValue: managedAgent.model,
icon: Cpu,
label: "Model",
testId: "user-profile-model",
});
} else if (persona?.model) {
fields.push({
copyValue: persona.model,
displayValue: persona.model,
icon: Cpu,
label: "Model",
testId: "user-profile-model",
});
}
if (managedAgent?.acpCommand) {
fields.push({
copyValue: managedAgent.acpCommand,
displayValue: managedAgent.acpCommand,
icon: Terminal,
label: "ACP command",
testId: "user-profile-acp",
});
}
if (managedAgent?.mcpCommand) {
fields.push({
copyValue: managedAgent.mcpCommand,
displayValue: managedAgent.mcpCommand,
icon: Terminal,
label: "MCP command",
testId: "user-profile-mcp",
});
}
if (managedAgent?.backend.type === "provider") {
const backendLabel = managedAgent.backend.id;
fields.push({
copyValue: backendLabel,
displayValue: backendLabel,
icon: Server,
label: "Backend",
testId: "user-profile-backend",
});
}
if (managedAgent) {
fields.push({
displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No",
icon: Server,
label: "Start on launch",
testId: "user-profile-start-on-launch",
});
fields.push({
displayValue: managedAgent.respondTo.replace(/-/g, " "),
icon: MessageSquare,
label: "Respond to",
testId: "user-profile-respond-to",
});
}
if (managedAgent?.lastError) {
fields.push({
copyValue: managedAgent.lastError,
displayValue: managedAgent.lastError,
icon: Activity,
label: "Last error",
testId: "user-profile-last-error",
});
}
return fields;
}
export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) {
const publicKeyLabel = "Public key";
const ownedByLabel = "Owned by";
const statusLabel = "Status";
const orderedFields = [
...fields.filter((field) => field.label === publicKeyLabel),
...fields.filter((field) => field.label === ownedByLabel),
...fields.filter(
(field) =>
field.label !== publicKeyLabel &&
field.label !== ownedByLabel &&
field.copyValue,
),
...fields.filter((field) => field.label === statusLabel),
...fields.filter((field) => {
if (
field.label === publicKeyLabel ||
field.label === ownedByLabel ||
field.label === statusLabel
) {
return false;
}
return !field.copyValue;
}),
];
return (
<section>
<div className="overflow-hidden rounded-2xl bg-muted/20">
{orderedFields.map((field) => (
<ProfileFieldRow field={field} key={field.testId ?? field.label} />
))}
</div>
</section>
);
}
function ProfileFieldRow({ field }: { field: ProfileField }) {
const Icon = field.icon;
const isCopyable = Boolean(field.copyValue);
const content = (
<>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block text-xs font-medium text-foreground">
{field.label}
</span>
<span
className="mt-0.5 block truncate text-sm text-muted-foreground"
title={field.displayValue}
>
{field.displayNode ?? field.displayValue}
</span>
</span>
{isCopyable ? (
<Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
</>
);
if (isCopyable && field.copyValue) {
return (
<button
aria-label={`Copy ${field.label}`}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40"
data-testid={field.testId}
onClick={() => void copyToClipboard(field.copyValue ?? "", field.label)}
title={`Copy ${field.label}`}
type="button"
>
{content}
</button>
);
}
return (
<div
className="flex items-center gap-3 px-4 py-3"
data-testid={field.testId}
>
{content}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateLinkedAgentRuntimeEdit } from "./UserProfilePanelPersonaSubmit.ts";
function agent(overrides = {}) {
return {
pubkey: "deadbeef".repeat(8),
name: "Fizz",
personaId: "persona-1",
relayUrl: "ws://localhost:3000",
acpCommand: "buzz-acp",
agentCommand: "goose",
agentArgs: [],
mcpCommand: "",
turnTimeoutSeconds: 320,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 1,
systemPrompt: "Prompt",
avatarUrl: null,
model: null,
mcpToolsets: null,
envVars: {},
status: "stopped",
pid: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
lastStartedAt: null,
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
logPath: null,
startOnAppLaunch: true,
backend: { type: "local" },
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
...overrides,
};
}
function persona(overrides = {}) {
return {
id: "persona-1",
displayName: "Fizz",
avatarUrl: null,
systemPrompt: "Prompt",
runtime: "goose",
model: null,
provider: null,
namePool: [],
isBuiltIn: false,
isActive: true,
envVars: {},
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function updateInput(overrides = {}) {
return {
id: "persona-1",
displayName: "Fizz",
avatarUrl: undefined,
systemPrompt: "Prompt",
runtime: "claude",
model: undefined,
provider: undefined,
namePool: [],
...overrides,
};
}
function runtime(overrides = {}) {
return {
id: "claude",
label: "Claude Code",
avatarUrl: "",
availability: "available",
command: "claude",
binaryPath: "/usr/local/bin/claude",
defaultArgs: [],
mcpCommand: null,
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: null,
...overrides,
};
}
test("validateLinkedAgentRuntimeEdit allows available runtime changes", () => {
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "claude" }),
managedAgent: agent(),
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime()],
}),
null,
);
});
test("validateLinkedAgentRuntimeEdit rejects unavailable linked-agent runtime changes", () => {
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "claude" }),
managedAgent: agent(),
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime({ availability: "cli_missing", command: null })],
}),
"Claude Code is not available. Install it before saving this linked agent.",
);
});
test("validateLinkedAgentRuntimeEdit allows unchanged or unlinked runtime preferences", () => {
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "goose" }),
managedAgent: agent(),
previousPersona: persona({ runtime: "goose" }),
runtimes: [],
}),
null,
);
assert.equal(
validateLinkedAgentRuntimeEdit({
input: updateInput({ runtime: "claude" }),
managedAgent: undefined,
previousPersona: persona({ runtime: "goose" }),
runtimes: [],
}),
null,
);
});

View File

@@ -0,0 +1,135 @@
import { toast } from "sonner";
import { personaManagedAgentUpdate } from "@/features/profile/ui/UserProfilePanelUtils";
import type {
AcpRuntimeCatalogEntry,
AgentPersona,
CreateManagedAgentResponse,
CreatePersonaInput,
ManagedAgent,
UpdateManagedAgentInput,
UpdatePersonaInput,
} from "@/shared/api/types";
type SubmitProfilePersonaDialogOptions = {
createManagedAgentForPersona: (
persona: AgentPersona,
) => Promise<CreateManagedAgentResponse>;
createPersona: (input: CreatePersonaInput) => Promise<AgentPersona>;
input: CreatePersonaInput | UpdatePersonaInput;
managedAgent: ManagedAgent | undefined;
onDone: () => void;
previousPersona?: AgentPersona;
runtimes?: readonly AcpRuntimeCatalogEntry[];
updateManagedAgent: (
input: UpdateManagedAgentInput,
) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>;
updatePersona: (input: UpdatePersonaInput) => Promise<AgentPersona>;
};
type ValidateLinkedAgentRuntimeEditOptions = {
input: UpdatePersonaInput;
managedAgent: ManagedAgent | undefined;
previousPersona?: AgentPersona;
runtimes?: readonly AcpRuntimeCatalogEntry[];
};
function normalizeRuntimePreference(value: string | null | undefined): string {
return value?.trim() ?? "";
}
export function validateLinkedAgentRuntimeEdit({
input,
managedAgent,
previousPersona,
runtimes,
}: ValidateLinkedAgentRuntimeEditOptions): string | null {
if (!managedAgent || !previousPersona) {
return null;
}
const previousRuntime = normalizeRuntimePreference(previousPersona.runtime);
const nextRuntime = normalizeRuntimePreference(input.runtime);
if (previousRuntime === nextRuntime) {
return null;
}
const runtime = runtimes?.find((candidate) => candidate.id === nextRuntime);
if (runtime?.availability === "available" && runtime.command) {
return null;
}
const runtimeLabel = runtime?.label ?? "This provider";
return `${runtimeLabel} is not available. Install it before saving this linked agent.`;
}
export async function submitProfilePersonaDialog({
createManagedAgentForPersona,
createPersona,
input,
managedAgent,
onDone,
previousPersona,
runtimes,
updateManagedAgent,
updatePersona,
}: SubmitProfilePersonaDialogOptions) {
try {
if ("id" in input) {
const runtimeEditError = validateLinkedAgentRuntimeEdit({
input,
managedAgent,
previousPersona,
runtimes,
});
if (runtimeEditError) {
toast.error(runtimeEditError);
return;
}
const persona = await updatePersona(input);
const agentUpdate = managedAgent
? personaManagedAgentUpdate(managedAgent, persona, {
previousPersona,
runtimes,
})
: null;
const result = agentUpdate ? await updateManagedAgent(agentUpdate) : null;
if (result?.profileSyncError) {
toast.warning(
`${result.agent.name} was updated, but profile sync failed: ${result.profileSyncError}`,
);
}
toast.success(`Updated ${input.displayName}.`);
} else {
const persona = await createPersona(input);
try {
const created = await createManagedAgentForPersona(persona);
if (created.spawnError) {
toast.error(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
);
} else {
toast.success(`Created and started ${created.agent.name}.`);
}
if (created.profileSyncError) {
toast.warning(
`${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`,
);
}
} catch (error) {
toast.error(
error instanceof Error
? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}`
: `${persona.displayName} was created, but the agent instance could not be created.`,
);
}
}
onDone();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to save agent.",
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseProfilePanelView,
personaManagedAgentUpdate,
profilePanelViewFromSearch,
} from "./UserProfilePanelUtils.ts";
function agent(overrides = {}) {
return {
pubkey: "deadbeef".repeat(8),
name: "Fizz",
personaId: "persona-1",
relayUrl: "ws://localhost:3000",
acpCommand: "buzz-acp",
agentCommand: "goose",
agentArgs: [],
mcpCommand: "",
turnTimeoutSeconds: 320,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 1,
systemPrompt: "Old prompt",
avatarUrl: "app-avatar://old",
model: "old-model",
mcpToolsets: null,
envVars: { OLD_KEY: "1" },
status: "stopped",
pid: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
lastStartedAt: null,
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
logPath: null,
startOnAppLaunch: true,
backend: { type: "local" },
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
...overrides,
};
}
function persona(overrides = {}) {
return {
id: "persona-1",
displayName: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
runtime: "goose",
model: "new-model",
provider: null,
namePool: [],
isBuiltIn: false,
isActive: true,
envVars: { NEW_KEY: "2" },
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function runtime(overrides = {}) {
return {
id: "claude",
label: "Claude Code",
avatarUrl: "app-avatar://claude",
availability: "available",
command: "claude",
binaryPath: "/usr/local/bin/claude",
defaultArgs: ["mcp", "serve"],
mcpCommand: "claude-mcp",
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: null,
...overrides,
};
}
test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => {
assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), {
pubkey: "deadbeef".repeat(8),
name: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
});
});
test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => {
assert.equal(
personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()),
null,
);
assert.equal(
personaManagedAgentUpdate(
agent({
name: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
}),
persona(),
),
null,
);
});
test("personaManagedAgentUpdate maps changed persona runtime to linked agent commands", () => {
assert.deepEqual(
personaManagedAgentUpdate(agent(), persona({ runtime: "claude" }), {
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime()],
}),
{
pubkey: "deadbeef".repeat(8),
name: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
agentCommand: "claude",
agentArgs: ["mcp", "serve"],
mcpCommand: "claude-mcp",
},
);
});
test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => {
assert.equal(
personaManagedAgentUpdate(
agent({
name: "Fizz Prime",
avatarUrl: null,
systemPrompt: "New prompt",
model: "new-model",
envVars: { NEW_KEY: "2" },
agentArgs: ["custom"],
}),
persona({ runtime: "goose" }),
{
previousPersona: persona({ runtime: "goose" }),
runtimes: [runtime({ id: "goose", command: "goose" })],
},
),
null,
);
});
test("parseProfilePanelView accepts all profile panel subviews", () => {
for (const view of [
"summary",
"info",
"settings",
"diagnostics",
"model",
"instructions",
"memories",
"channels",
"logs",
]) {
assert.equal(parseProfilePanelView(view), view);
}
});
test("profilePanelViewFromSearch falls back to summary for invalid values", () => {
assert.equal(parseProfilePanelView("missing"), null);
assert.equal(profilePanelViewFromSearch("missing"), "summary");
assert.equal(profilePanelViewFromSearch(null), "summary");
});

View File

@@ -0,0 +1,326 @@
import * as React from "react";
import type {
AcpRuntimeCatalogEntry,
AgentPersona,
Channel,
ManagedAgent,
Profile,
RelayAgent,
UpdateManagedAgentInput,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ProfileChannelLink = {
id: string;
name: string;
};
export type ProfilePanelView =
| "summary"
| "info"
| "settings"
| "diagnostics"
| "model"
| "instructions"
| "memories"
| "channels"
| "logs";
export const PROFILE_PANEL_VIEW_TITLES: Record<ProfilePanelView, string> = {
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<ProfilePanelView>(
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<HTMLButtonElement>) => 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<string, ProfileChannelLink>();
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 | undefined>
): 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<string | null | undefined>,
): Profile | undefined {
const profileAvatarUrl = normalizeProfileFallbackAvatarUrl(
profile?.avatarUrl,
);
const avatarUrl = resolveProfileAvatarUrl(
profileAvatarUrl,
...fallbackAvatarUrls.map((avatarUrl) =>
normalizeProfileFallbackAvatarUrl(avatarUrl),
),
);
return profile && avatarUrl !== profile.avatarUrl
? { ...profile, avatarUrl }
: profile;
}
function normalizeProfileFallbackAvatarUrl(
avatarUrl: string | null | undefined,
): string | null {
const trimmed = avatarUrl?.trim();
if (!trimmed) return null;
return trimmed;
}
export function resolveProfileDisplayName({
persona,
profile,
pubkey,
}: {
persona: AgentPersona | undefined;
profile: Profile | undefined;
pubkey: string | null;
}) {
return (
profile?.displayName ??
persona?.displayName ??
(pubkey ? truncatePubkey(pubkey) : "Agent")
);
}
export function resolveOwnerHandle(
profile: Profile | undefined,
currentPubkey: string | undefined,
) {
if (currentPubkey === undefined) {
return null;
}
return (
profile?.nip05Handle?.trim() ||
profile?.displayName?.trim() ||
truncatePubkey(currentPubkey)
);
}
export function resolveAgentInstruction(
managedAgent: ManagedAgent | undefined,
persona: AgentPersona | undefined,
) {
return (
managedAgent?.systemPrompt?.trim() || persona?.systemPrompt.trim() || null
);
}
export function personaManagedAgentUpdate(
agent: ManagedAgent,
persona: AgentPersona,
options: {
previousPersona?: AgentPersona;
runtimes?: readonly AcpRuntimeCatalogEntry[];
} = {},
): UpdateManagedAgentInput | null {
if (agent.personaId !== persona.id) return null;
const input: UpdateManagedAgentInput = { pubkey: agent.pubkey };
let hasChanges = false;
if (persona.displayName !== agent.name) {
input.name = persona.displayName;
hasChanges = true;
}
if (persona.systemPrompt !== (agent.systemPrompt ?? "")) {
input.systemPrompt = persona.systemPrompt;
hasChanges = true;
}
if ((persona.model ?? null) !== (agent.model ?? null)) {
input.model = persona.model;
hasChanges = true;
}
if (!stringRecordEqual(persona.envVars, agent.envVars)) {
input.envVars = persona.envVars;
hasChanges = true;
}
const runtimeChanged =
options.previousPersona !== undefined &&
options.previousPersona.runtime !== persona.runtime;
const runtime = runtimeChanged
? options.runtimes?.find((candidate) => candidate.id === persona.runtime)
: undefined;
if (runtime?.command) {
if (runtime.command !== agent.agentCommand) {
input.agentCommand = runtime.command;
hasChanges = true;
}
if (!stringArrayEqual(runtime.defaultArgs, agent.agentArgs)) {
input.agentArgs = [...runtime.defaultArgs];
hasChanges = true;
}
const mcpCommand = runtime.mcpCommand ?? "";
if (mcpCommand !== agent.mcpCommand) {
input.mcpCommand = mcpCommand;
hasChanges = true;
}
}
return hasChanges ? input : null;
}
function stringArrayEqual(left: readonly string[], right: readonly string[]) {
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function stringRecordEqual(
left: Record<string, string>,
right: Record<string, string>,
) {
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)
);
}

View File

@@ -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<void>;
}) {
return (
<>
<PersonaDialog
description={personaDialogState?.description ?? ""}
error={updateError ?? createError}
initialValues={personaDialogState?.initialValues ?? null}
isPending={isPending}
runtimes={runtimes}
runtimesLoading={runtimesLoading}
onOpenChange={(open) => {
if (!open) {
onCloseDialog();
}
}}
onSubmit={onSubmit}
open={personaDialogState !== null}
submitLabel={personaDialogState?.submitLabel ?? "Save"}
title={personaDialogState?.title ?? "Agent"}
/>
<PersonaDeleteDialog
onConfirm={onConfirmDelete}
onOpenChange={(open) => {
if (!open) {
onCloseDelete();
}
}}
open={personaToDelete !== null}
persona={personaToDelete}
/>
</>
);
}

View File

@@ -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<ProfilePanelContextValue>({
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 (