diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 33ca2e5bb..955cd84dd 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { + BookmarkPlus, ChevronDown, ChevronRight, Clipboard, @@ -39,6 +40,8 @@ import { EditAgentDialog } from "./EditAgentDialog"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { ModelPicker } from "./ModelPicker"; +import { PersonaDialog } from "./PersonaDialog"; +import { useSaveAsPersonaTemplate } from "./useSaveAsPersonaTemplate"; import { truncatePubkey } from "./agentUi"; export function ManagedAgentRow({ @@ -423,6 +426,7 @@ function AgentActionsMenu({ onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void; }) { const [editOpen, setEditOpen] = React.useState(false); + const saveAsTemplate = useSaveAsPersonaTemplate(); return ( <> @@ -483,6 +487,13 @@ function AgentActionsMenu({ ) : null} + {agent.personaId === null ? ( + saveAsTemplate.open(agent)}> + + Save as persona template + + ) : null} + onAddToChannel(agent)} @@ -542,6 +553,10 @@ function AgentActionsMenu({ open={editOpen} /> ) : null} + + {saveAsTemplate.dialogState ? ( + + ) : null} ); } diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index 948ae77f1..b4969aea0 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -6,6 +6,7 @@ import { duplicatePersonaDialogState, editPersonaDialogState, importPersonaDialogState, + saveAsPersonaTemplateDialogState, } from "./personaDialogState.ts"; test("createPersonaDialogState returns a fresh empty draft", () => { @@ -228,3 +229,107 @@ test("importPersonaDialogState preserves provider=anthropic", () => { assert.equal(state.initialValues.provider, "anthropic"); }); + +// ── saveAsPersonaTemplateDialogState (promote an agent → persona template) ──── + +/** Minimal ManagedAgent fixture; only the fields the builder reads matter. */ +function makeAgent(overrides = {}) { + return { + pubkey: "npub-agent", + name: "Scout", + personaId: null, + relayUrl: "wss://relay", + acpCommand: "", + agentCommand: "/usr/local/bin/goose-acp", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 320, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 24, + systemPrompt: "Scout the codebase.", + model: "claude-sonnet", + mcpToolsets: null, + envVars: { ANTHROPIC_API_KEY: "sk-test" }, + status: "stopped", + pid: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + ...overrides, + }; +} + +test("saveAsPersonaTemplateDialogState uses persona-template vocabulary", () => { + const state = saveAsPersonaTemplateDialogState(makeAgent(), []); + + assert.equal(state.title, "Save as persona template"); + assert.equal(state.submitLabel, "Save as persona template"); + assert.equal(state.description, "Reuse this setup to create more agents."); +}); + +test("saveAsPersonaTemplateDialogState reverse-maps agentCommand to a runtime id", () => { + // commandsMatch compares basenames, so the absolute agentCommand path + // resolves to the catalog runtime whose command shares that basename. + const state = saveAsPersonaTemplateDialogState(makeAgent(), [ + { id: "claude", label: "Claude", command: "claude-code-acp" }, + { id: "goose", label: "Goose", command: "goose-acp" }, + ]); + + assert.equal(state.initialValues.runtime, "goose"); +}); + +test("saveAsPersonaTemplateDialogState leaves runtime undefined when nothing matches", () => { + // Graceful fallback: an unknown command (or an empty/unloaded catalog) + // yields no runtime, and the dialog falls back to its default behavior. + const noMatch = saveAsPersonaTemplateDialogState(makeAgent(), [ + { id: "claude", label: "Claude", command: "claude-code-acp" }, + ]); + const empty = saveAsPersonaTemplateDialogState(makeAgent(), []); + + assert.equal(noMatch.initialValues.runtime, undefined); + assert.equal(empty.initialValues.runtime, undefined); +}); + +test("saveAsPersonaTemplateDialogState ignores catalog entries with a null command", () => { + const state = saveAsPersonaTemplateDialogState(makeAgent(), [ + { id: "not-installed", label: "Not Installed", command: null }, + { id: "goose", label: "Goose", command: "goose-acp" }, + ]); + + assert.equal(state.initialValues.runtime, "goose"); +}); + +test("saveAsPersonaTemplateDialogState carries name, prompt, model, and envVars", () => { + const state = saveAsPersonaTemplateDialogState(makeAgent(), []); + + assert.equal(state.initialValues.displayName, "Scout"); + assert.equal(state.initialValues.systemPrompt, "Scout the codebase."); + assert.equal(state.initialValues.model, "claude-sonnet"); + assert.deepEqual(state.initialValues.envVars, { + ANTHROPIC_API_KEY: "sk-test", + }); + // namePool is persona-only and starts empty for the user to fill. + assert.deepEqual(state.initialValues.namePool, []); +}); + +test("saveAsPersonaTemplateDialogState omits provider (no top-level field on ManagedAgent)", () => { + // A ManagedAgent has no top-level provider, so there is nothing lossless to + // carry — the builder must not invent one. + const state = saveAsPersonaTemplateDialogState(makeAgent(), []); + + assert.equal("provider" in state.initialValues, false); +}); + +test("saveAsPersonaTemplateDialogState tolerates null systemPrompt and model", () => { + const state = saveAsPersonaTemplateDialogState( + makeAgent({ systemPrompt: null, model: null }), + [], + ); + + assert.equal(state.initialValues.systemPrompt, ""); + assert.equal(state.initialValues.model, undefined); +}); diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index d1631dea5..92764bba0 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -1,7 +1,10 @@ +import { commandsMatch } from "@/features/agents/agentReuse"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import type { + AcpRuntimeCatalogEntry, AgentPersona, CreatePersonaInput, + ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; @@ -56,6 +59,60 @@ export function duplicatePersonaDialogState( }; } +/** + * Reverse-map a managed agent's resolved harness command back to an ACP + * runtime ID, so the persona dialog can pre-select the matching runtime. + * Returns `undefined` when no runtime matches (or none are loaded yet) — the + * dialog then falls back to its default-runtime behavior. + */ +function runtimeIdForAgentCommand( + agentCommand: string, + runtimes: readonly AcpRuntimeCatalogEntry[], +): string | undefined { + const match = runtimes.find( + (runtime) => + runtime.command !== null && commandsMatch(runtime.command, agentCommand), + ); + return match?.id; +} + +/** + * Dialog state for the opt-in "Save as persona template" action on an existing + * agent. Prefills the persona editor from the agent so the user reviews and + * confirms before a persona template is created — nothing is minted silently. + * + * Near-lossless promote: name, system prompt, model, and env vars copy + * straight across; the harness command reverse-maps to a runtime ID. + * `namePool` is persona-only and starts empty — the user can fill it in the + * same dialog (it's how a template bulk-adds bots later). + * + * Note: "persona template" is the UI name for what the backend calls a + * `persona` (kind:30175). This builder produces a backend `CreatePersonaInput`. + * `provider` is intentionally not carried: a `ManagedAgent` does not expose a + * top-level provider field (it lives in backend/provider config), so there is + * nothing lossless to copy here. + */ +export function saveAsPersonaTemplateDialogState( + agent: ManagedAgent, + runtimes: readonly AcpRuntimeCatalogEntry[], +): PersonaDialogState { + return { + title: "Save as persona template", + description: "Reuse this setup to create more agents.", + submitLabel: "Save as persona template", + initialValues: { + displayName: agent.name, + avatarUrl: "", + systemPrompt: agent.systemPrompt ?? "", + runtime: runtimeIdForAgentCommand(agent.agentCommand, runtimes), + model: agent.model ?? undefined, + // namePool is persona-only; start empty so the user fills it here. + namePool: [], + envVars: agent.envVars ?? {}, + }, + }; +} + export function editPersonaDialogState( persona: AgentPersona, ): PersonaDialogState { diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 9ea9fc258..2c10f1b84 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -1,3 +1,23 @@ +/** + * UI copy for the persona library surfaces. + * + * ── Vocabulary boundary (UI ↔ backend) ────────────────────────────────────── + * The product-facing term is **"persona template"**: a reusable setup a user + * can save once and reuse to create more agents. The backend has no separate + * "template" concept — a persona template IS a **`persona`** (Nostr + * **kind:30175**). There is intentionally zero drift between the two: + * + * UI string backend entity / mutation + * ─────────────────────── ──────────────────────────────────────────────── + * "persona template" ⇄ `persona` (kind:30175) + * "Save as persona ⇄ `createPersonaMutation` / `CreatePersonaInput` + * template" + * + * So every "...persona template" label here, in `CreateAgentDialog`'s opt-in + * toggle, and in the `useSaveAsPersonaTemplate` / `saveAsPersonaTemplateDialogState` + * save-as flow maps to the same backend `persona`. Keep new persona-template + * copy in this file and keep the mapping above current if the vocabulary moves. + */ export const personaLibraryCopy = { title: "My agents", description: diff --git a/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts new file mode 100644 index 000000000..d0757fb53 --- /dev/null +++ b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts @@ -0,0 +1,99 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + useAcpRuntimesQuery, + useCreatePersonaMutation, +} from "@/features/agents/hooks"; +import type { + CreatePersonaInput, + ManagedAgent, + UpdatePersonaInput, +} from "@/shared/api/types"; +import { + saveAsPersonaTemplateDialogState, + type PersonaDialogState, +} from "./personaDialogState"; + +/** + * Self-contained "Save as persona template" flow for surfaces that don't + * already host `usePersonaActions` (e.g. an agent row's actions menu or the + * sidebar agent profile). Opens the shared `PersonaDialog` prefilled from an + * agent and creates a backend persona on submit — no new backend or IPC. + * + * Keeping the dialog + mutation behind this hook (rather than welding them to + * a host component's layout) means a later visual reskin of the create surface + * can move the trigger without untangling the save-as behavior. + * + * "Persona template" is the UI name for what the backend calls a `persona` + * (kind:30175); this hook produces a `CreatePersonaInput`. + */ +export function useSaveAsPersonaTemplate() { + // Hold the agent being promoted (rather than a one-shot snapshot) so the + // runtime reverse-map can re-resolve when the runtime list arrives — the + // query is lazy (`enabled` flips on open), so its data is usually empty on + // the very first open. Deriving the dialog state from `agent` + live + // runtimes via a memo avoids that first-open race. + const [agent, setAgent] = React.useState(null); + // Only fetch runtimes once the user actually opens the dialog. + const acpRuntimesQuery = useAcpRuntimesQuery({ enabled: agent !== null }); + const createPersonaMutation = useCreatePersonaMutation(); + + const dialogState = React.useMemo( + () => + agent + ? saveAsPersonaTemplateDialogState(agent, acpRuntimesQuery.data ?? []) + : null, + [agent, acpRuntimesQuery.data], + ); + + const open = React.useCallback((nextAgent: ManagedAgent) => { + setAgent(nextAgent); + }, []); + + const close = React.useCallback(() => { + setAgent(null); + }, []); + + const handleSubmit = React.useCallback( + async (input: CreatePersonaInput | UpdatePersonaInput) => { + // The save-as flow only ever produces a create input. + if ("id" in input) return; + try { + await createPersonaMutation.mutateAsync(input); + toast.success(`Saved ${input.displayName} as a persona template.`); + setAgent(null); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to save persona template.", + ); + } + }, + [createPersonaMutation], + ); + + return { + open, + dialogState, + dialogProps: { + open: dialogState !== null, + title: dialogState?.title ?? "", + description: dialogState?.description ?? "", + submitLabel: dialogState?.submitLabel ?? "", + initialValues: dialogState?.initialValues ?? null, + error: + createPersonaMutation.error instanceof Error + ? createPersonaMutation.error + : null, + isPending: createPersonaMutation.isPending, + runtimes: acpRuntimesQuery.data ?? [], + runtimesLoading: acpRuntimesQuery.isLoading, + onOpenChange: (next: boolean) => { + if (!next) close(); + }, + onSubmit: handleSubmit, + }, + }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index ad2af7c4a..cca5c20f5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -14,6 +14,8 @@ import { import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; +import { PersonaDialog } from "@/features/agents/ui/PersonaDialog"; +import { useSaveAsPersonaTemplate } from "@/features/agents/ui/useSaveAsPersonaTemplate"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { @@ -242,6 +244,17 @@ export function UserProfilePanel({ setEditAgentOpen(true); }, []); + const saveAsTemplate = useSaveAsPersonaTemplate(); + const canSaveAsTemplate = + canEditAgent && + managedAgent !== undefined && + managedAgent.personaId === null; + const handleSaveAsTemplate = React.useCallback(() => { + if (managedAgent) { + saveAsTemplate.open(managedAgent); + } + }, [managedAgent, saveAsTemplate]); + const handleOpenActivity = React.useCallback(() => { onClose(); onOpenAgentSession?.(pubkey); @@ -353,6 +366,8 @@ export function UserProfilePanel({ handleEditAgent={handleEditAgent} handleMessage={handleMessage} handleOpenActivity={handleOpenActivity} + handleSaveAsTemplate={handleSaveAsTemplate} + canSaveAsTemplate={canSaveAsTemplate} isBot={isBot} isFollowing={isFollowing} isOwner={isOwner} @@ -405,6 +420,10 @@ export function UserProfilePanel({ /> ) : null; + const saveAsTemplateDialog = saveAsTemplate.dialogState ? ( + + ) : null; + if (isSplitLayout) { return ( <> @@ -416,6 +435,7 @@ export function UserProfilePanel({ {profileBody} {editAgentDialog} + {saveAsTemplateDialog} ); } @@ -481,6 +501,7 @@ export function UserProfilePanel({ {profileBody} {editAgentDialog} + {saveAsTemplateDialog} ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index b00a98673..4c5f546a9 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react"; import { Activity, ArrowUpRight, + BookmarkPlus, Brain, ChevronDown, ChevronRight, @@ -73,6 +74,8 @@ export type ProfileSummaryViewProps = { handleEditAgent: () => void; handleMessage: () => void; handleOpenActivity: () => void; + handleSaveAsTemplate: () => void; + canSaveAsTemplate: boolean; isBot: boolean; isFollowing: boolean; isOwner: boolean | undefined; @@ -108,6 +111,8 @@ export function ProfileSummaryView({ handleEditAgent, handleMessage, handleOpenActivity, + handleSaveAsTemplate, + canSaveAsTemplate, isBot, isFollowing, isOwner, @@ -174,8 +179,10 @@ export function ProfileSummaryView({ {!isSelf ? ( ; isFollowing: boolean; onEditAgent: () => void; + onSaveAsTemplate: () => void; onMessage?: () => void; pubkey: string; unfollowMutation: ReturnType; @@ -481,6 +492,14 @@ function ProfilePrimaryActions({ testId="user-profile-edit-agent" /> ) : null} + {canSaveAsTemplate ? ( + + ) : null} ); }