-
- onToggle(groupKey)}
- type="button"
- >
- {isCollapsed ? (
-
- ) : (
-
- )}
- {label}
-
- ({agents.length})
-
-
-
- {!isCollapsed ?
: null}
+
+
onToggle(groupKey)}
+ type="button"
+ >
+ {isCollapsed ? (
+
+ ) : (
+
+ )}
+ {label}
+ ({agents.length})
+
+ {!isCollapsed ? (
+
+ {agents.map((agent) => (
+
+ ))}
+
+ ) : null}
);
}
diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs
index 7e39b7288..1dcda856a 100644
--- a/desktop/src/features/agents/ui/personaDialogState.test.mjs
+++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs
@@ -7,8 +7,54 @@ import {
duplicatePersonaDialogState,
editPersonaDialogState,
importPersonaDialogState,
+ saveAsPersonaTemplateDialogState,
} from "./personaDialogState.ts";
+// Minimal ManagedAgent fixture — only the fields the save-as mapping reads.
+function makeManagedAgent(overrides = {}) {
+ return {
+ pubkey: "agentpub",
+ name: "Helper",
+ personaId: null,
+ relayUrl: "wss://relay",
+ acpCommand: "goose",
+ agentCommand: "/usr/local/bin/goose",
+ agentCommandOverride: null,
+ agentArgs: [],
+ mcpCommand: "",
+ turnTimeoutSeconds: 0,
+ idleTimeoutSeconds: null,
+ maxTurnDurationSeconds: null,
+ parallelism: 1,
+ systemPrompt: "Be helpful.",
+ model: "claude-sonnet",
+ provider: "anthropic",
+ personaOutOfDate: false,
+ personaOrphaned: false,
+ mcpToolsets: null,
+ envVars: { ANTHROPIC_API_KEY: "sk-test" },
+ ...overrides,
+ };
+}
+
+function makeRuntime(overrides = {}) {
+ return {
+ id: "goose",
+ label: "Goose",
+ avatarUrl: "",
+ availability: "available",
+ command: "goose",
+ binaryPath: "/usr/local/bin/goose",
+ defaultArgs: [],
+ mcpCommand: null,
+ installHint: "",
+ installInstructionsUrl: "",
+ canAutoInstall: false,
+ underlyingCliPath: null,
+ ...overrides,
+ };
+}
+
test("canSubmitPersonaDialog requires a display name but not a system prompt", () => {
// Empty system prompt is allowed: core memory is auto-injected, so the
// persona prompt is optional. Only the display name gates submission.
@@ -258,3 +304,85 @@ test("importPersonaDialogState preserves provider=anthropic", () => {
assert.equal(state.initialValues.provider, "anthropic");
});
+
+test("saveAsPersonaTemplateDialogState carries agent config into a create draft", () => {
+ const state = saveAsPersonaTemplateDialogState(makeManagedAgent(), [
+ makeRuntime(),
+ ]);
+
+ 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.");
+ assert.deepEqual(state.initialValues, {
+ displayName: "Helper",
+ avatarUrl: "",
+ systemPrompt: "Be helpful.",
+ // Reverse-mapped from agentCommand basename → matching runtime id.
+ runtime: "goose",
+ model: "claude-sonnet",
+ provider: "anthropic",
+ // Persona-only field starts empty; the user fills it in the dialog.
+ namePool: [],
+ envVars: { ANTHROPIC_API_KEY: "sk-test" },
+ });
+});
+
+test("saveAsPersonaTemplateDialogState reverse-maps the runtime by command basename", () => {
+ // Agent's resolved command is an absolute path; the runtime exposes a bare
+ // command. commandsMatch normalizes on basename, so they should still pair.
+ const state = saveAsPersonaTemplateDialogState(
+ makeManagedAgent({ agentCommand: "/opt/homebrew/bin/goose" }),
+ [makeRuntime({ id: "goose-runtime", command: "goose" })],
+ );
+
+ assert.equal(state.initialValues.runtime, "goose-runtime");
+});
+
+test("saveAsPersonaTemplateDialogState falls back to undefined runtime when none match", () => {
+ // No runtime matches the agent command, or runtimes not loaded yet — the
+ // dialog then uses its own default-runtime behavior.
+ const noMatch = saveAsPersonaTemplateDialogState(
+ makeManagedAgent({ agentCommand: "claude-code-acp" }),
+ [makeRuntime({ command: "goose" })],
+ );
+ assert.equal(noMatch.initialValues.runtime, undefined);
+
+ const noRuntimes = saveAsPersonaTemplateDialogState(makeManagedAgent(), []);
+ assert.equal(noRuntimes.initialValues.runtime, undefined);
+});
+
+test("saveAsPersonaTemplateDialogState skips runtimes with a null command", () => {
+ // Catalog entries can be unavailable (command: null). Those must not throw
+ // and must not match — only resolvable commands participate in the map.
+ const state = saveAsPersonaTemplateDialogState(makeManagedAgent(), [
+ makeRuntime({ id: "uninstalled", command: null, availability: "missing" }),
+ makeRuntime({ id: "goose", command: "goose" }),
+ ]);
+
+ assert.equal(state.initialValues.runtime, "goose");
+});
+
+test("saveAsPersonaTemplateDialogState maps a null provider/model/systemPrompt to undefined/empty", () => {
+ const state = saveAsPersonaTemplateDialogState(
+ makeManagedAgent({ provider: null, model: null, systemPrompt: null }),
+ [],
+ );
+
+ assert.equal(state.initialValues.provider, undefined);
+ assert.equal(state.initialValues.model, undefined);
+ assert.equal(state.initialValues.systemPrompt, "");
+});
+
+test("default managed-agent create stays persona-less (no personaId set)", () => {
+ // Part 1 regression guard. A default agent create must not carry a
+ // personaId — that linkage only exists when a persona/template is chosen.
+ // The save-as flow promotes an existing agent INTO a template; it never
+ // back-fills personaId onto the source agent.
+ const agent = makeManagedAgent();
+ assert.equal(agent.personaId, null);
+
+ // The promote produces a CreatePersonaInput (no agent personaId mutation),
+ // and a persona-create draft has no personaId field at all.
+ const state = saveAsPersonaTemplateDialogState(agent, []);
+ assert.equal("personaId" in state.initialValues, false);
+});
diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts
index 0a8c2a914..792121c9e 100644
--- a/desktop/src/features/agents/ui/personaDialogState.ts
+++ b/desktop/src/features/agents/ui/personaDialogState.ts
@@ -1,9 +1,12 @@
import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas";
import type {
+ AcpRuntimeCatalogEntry,
AgentPersona,
CreatePersonaInput,
+ ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
+import { commandsMatch } from "@/features/agents/agentReuse";
export type PersonaDialogState = {
description: string;
@@ -70,6 +73,58 @@ 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, provider, 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`.
+ */
+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,
+ provider: agent.provider ?? 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..bd1e77026 100644
--- a/desktop/src/features/agents/ui/personaLibraryCopy.ts
+++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts
@@ -1,3 +1,7 @@
+// Naming boundary: user-facing copy says "persona template" (the reusable
+// setup users save and reuse), while the backend type/storage stays `persona`
+// (kind:30175, builtin:* ids, .persona.md). Do NOT rename backend symbols to
+// match the UI — the two names map across this boundary intentionally.
export const personaLibraryCopy = {
title: "My agents",
description:
diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.ts b/desktop/src/features/agents/ui/unifiedAgentGroups.ts
new file mode 100644
index 000000000..a2d1b987f
--- /dev/null
+++ b/desktop/src/features/agents/ui/unifiedAgentGroups.ts
@@ -0,0 +1,44 @@
+import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
+import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
+
+type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] };
+
+export function buildUnifiedGroups(
+ personas: AgentPersona[],
+ agents: ManagedAgent[],
+) {
+ const byPersonaId = new Map
();
+ const ungrouped: ManagedAgent[] = [];
+
+ for (const agent of agents) {
+ if (!agent.personaId) {
+ ungrouped.push(agent);
+ } else {
+ const list = byPersonaId.get(agent.personaId) ?? [];
+ list.push(agent);
+ byPersonaId.set(agent.personaId, list);
+ }
+ }
+
+ const matched = new Set();
+ const groups: PersonaGroup[] = personas.map((persona) => {
+ matched.add(persona.id);
+ return { persona, agents: byPersonaId.get(persona.id) ?? [] };
+ });
+
+ const unknown: ManagedAgent[] = [];
+ for (const [id, list] of byPersonaId) {
+ if (!matched.has(id)) unknown.push(...list);
+ }
+
+ return { groups, ungrouped, unknown };
+}
+
+export function pickProfileAgent(agents: ManagedAgent[]) {
+ return [...agents].sort((left, right) => {
+ const activeDiff =
+ Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left));
+ if (activeDiff !== 0) return activeDiff;
+ return left.name.localeCompare(right.name);
+ })[0];
+}
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts
index 76fd78b3c..369195a51 100644
--- a/desktop/src/features/agents/ui/usePersonaActions.ts
+++ b/desktop/src/features/agents/ui/usePersonaActions.ts
@@ -20,6 +20,7 @@ import { isSingleItemFile } from "@/shared/lib/fileMagic";
import type {
AgentPersona,
CreatePersonaInput,
+ ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
import {
@@ -27,6 +28,7 @@ import {
duplicatePersonaDialogState,
editPersonaDialogState,
importPersonaDialogState,
+ saveAsPersonaTemplateDialogState,
type PersonaDialogState,
} from "./personaDialogState";
import { usePersonaImportActions } from "./usePersonaImportActions";
@@ -203,6 +205,17 @@ export function usePersonaActions() {
setPersonaDialogState(duplicatePersonaDialogState(persona));
}
+ function openSaveAsTemplate(agent: ManagedAgent) {
+ clearFeedback("library");
+ setShouldLoadAcpRuntimes(true);
+ // Reverse-map against whatever runtimes are already cached; the dialog
+ // refines once the lazy query resolves. Best-effort — falls back to the
+ // default runtime when no match is available yet.
+ setPersonaDialogState(
+ saveAsPersonaTemplateDialogState(agent, acpRuntimesQuery.data ?? []),
+ );
+ }
+
function openCatalog() {
clearFeedback("catalog");
setIsCatalogDialogOpen(true);
@@ -252,6 +265,7 @@ export function usePersonaActions() {
openCreate,
openEdit,
openDuplicate,
+ openSaveAsTemplate,
openCatalog,
openDelete,
clearFeedback,
diff --git a/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts
new file mode 100644
index 000000000..716359e4f
--- /dev/null
+++ b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts
@@ -0,0 +1,90 @@
+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 outside the
+ * Agents page (e.g. the sidebar agent profile) that don't already host
+ * `usePersonaActions`. Opens the shared `PersonaDialog` prefilled from an
+ * agent and creates a backend persona on submit — no new backend or IPC.
+ *
+ * "Persona template" is the UI name for what the backend calls a `persona`
+ * (kind:30175); this hook produces a `CreatePersonaInput`.
+ */
+export function useSaveAsPersonaTemplate() {
+ const [dialogState, setDialogState] =
+ React.useState(null);
+ // Only fetch runtimes once the user actually opens the dialog.
+ const [shouldLoadRuntimes, setShouldLoadRuntimes] = React.useState(false);
+ const acpRuntimesQuery = useAcpRuntimesQuery({ enabled: shouldLoadRuntimes });
+ const createPersonaMutation = useCreatePersonaMutation();
+
+ const open = React.useCallback(
+ (agent: ManagedAgent) => {
+ setShouldLoadRuntimes(true);
+ setDialogState(
+ saveAsPersonaTemplateDialogState(agent, acpRuntimesQuery.data ?? []),
+ );
+ },
+ [acpRuntimesQuery.data],
+ );
+
+ const close = React.useCallback(() => {
+ setDialogState(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.`);
+ setDialogState(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/ProfileQuickActions.tsx b/desktop/src/features/profile/ui/ProfileQuickActions.tsx
new file mode 100644
index 000000000..267354481
--- /dev/null
+++ b/desktop/src/features/profile/ui/ProfileQuickActions.tsx
@@ -0,0 +1,88 @@
+import * as React from "react";
+import type { LucideIcon } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+
+/**
+ * A single circular quick action (icon + label) in the profile summary's
+ * action row — e.g. Follow / Message / Edit.
+ */
+export function ProfileQuickAction({
+ active,
+ disabled,
+ icon: Icon,
+ label,
+ onClick,
+ testId,
+}: {
+ active?: boolean;
+ disabled?: boolean;
+ icon: LucideIcon;
+ label: string;
+ onClick: () => void;
+ testId?: string;
+}) {
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+}
+
+/**
+ * A quick action styled to match `ProfileQuickAction` but rendered as a
+ * `forwardRef` button so it can be a Radix `DropdownMenuTrigger asChild`
+ * (which clones the child and injects a ref + `aria-*`/event props). Used for
+ * the overflow `⋮` that hosts actions like "Save as persona template".
+ */
+export const ProfileQuickActionTrigger = React.forwardRef<
+ HTMLButtonElement,
+ {
+ ariaLabel: string;
+ icon: LucideIcon;
+ label: string;
+ testId?: string;
+ } & React.ComponentPropsWithoutRef<"button">
+>(function ProfileQuickActionTrigger(
+ { ariaLabel, icon: Icon, label, testId, ...props },
+ ref,
+) {
+ return (
+
+
+
+
+ {label}
+
+ );
+});
diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx
index 335202c22..d5a6c67ca 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 {
@@ -278,6 +280,17 @@ export function UserProfilePanel({
setEditAgentOpen(true);
}, []);
+ // "Save as persona template" flow for the sidebar profile. Self-contained
+ // (own dialog + create mutation) so it works outside the Agents page, which
+ // is where `usePersonaActions` lives. Gated on `canEditAgent` below — only a
+ // managed agent the viewer owns can be promoted.
+ const saveAsTemplate = useSaveAsPersonaTemplate();
+ const handleSaveAsTemplate = React.useCallback(() => {
+ if (managedAgent) {
+ saveAsTemplate.open(managedAgent);
+ }
+ }, [managedAgent, saveAsTemplate]);
+
const handleOpenActivity = React.useCallback(() => {
onClose();
onOpenAgentSession?.(pubkey);
@@ -389,6 +402,7 @@ export function UserProfilePanel({
handleEditAgent={handleEditAgent}
handleMessage={handleMessage}
handleOpenActivity={handleOpenActivity}
+ handleSaveAsTemplate={canEditAgent ? handleSaveAsTemplate : undefined}
isBot={isBot}
isFollowing={isFollowing}
isOwner={viewerIsOwner}
@@ -441,6 +455,11 @@ export function UserProfilePanel({
/>
) : null;
+ // Render unconditionally — the dialog stays closed until `open()` seeds it.
+ const saveAsTemplateDialog = canEditAgent ? (
+
+ ) : null;
+
if (isSplitLayout) {
return (
<>
@@ -452,6 +471,7 @@ export function UserProfilePanel({
{profileBody}
{editAgentDialog}
+ {saveAsTemplateDialog}
>
);
}
@@ -517,6 +537,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 42a3739c3..67f1f7c98 100644
--- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
@@ -3,12 +3,14 @@ import type { LucideIcon } from "lucide-react";
import {
Activity,
ArrowUpRight,
+ BookmarkPlus,
Brain,
ChevronDown,
ChevronRight,
ChevronUp,
Copy,
Cpu,
+ Ellipsis,
Fingerprint,
Hash,
MessageSquare,
@@ -42,7 +44,17 @@ import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import { Badge } from "@/shared/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/shared/ui/dropdown-menu";
import { UserAvatar } from "@/shared/ui/UserAvatar";
+import {
+ ProfileQuickAction,
+ ProfileQuickActionTrigger,
+} from "./ProfileQuickActions";
const RUNTIME_LABELS: Record