mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
Move agent management into profile sidebar
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
105
desktop/src/features/profile/ui/UserProfileAgentActions.tsx
Normal file
105
desktop/src/features/profile/ui/UserProfileAgentActions.tsx
Normal 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
159
desktop/src/features/profile/ui/UserProfilePanelDeletion.ts
Normal file
159
desktop/src/features/profile/ui/UserProfilePanelDeletion.ts
Normal 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 {};
|
||||
}
|
||||
491
desktop/src/features/profile/ui/UserProfilePanelFields.tsx
Normal file
491
desktop/src/features/profile/ui/UserProfilePanelFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
135
desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts
Normal file
135
desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts
Normal 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
176
desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs
Normal file
176
desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs
Normal 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");
|
||||
});
|
||||
326
desktop/src/features/profile/ui/UserProfilePanelUtils.ts
Normal file
326
desktop/src/features/profile/ui/UserProfilePanelUtils.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user