Skip to content
Merged
164 changes: 149 additions & 15 deletions desktop/src-tauri/src/managed_agents/personas.rs

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,20 @@ fn merge_personas_adds_missing_built_ins() {
assert!(changed);
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
assert!(records.iter().all(|record| record.is_builtin));
assert!(records.iter().all(|record| record.is_active));
let display_names: Vec<&str> = records
.iter()
.map(|record| record.display_name.as_str())
.collect();
assert_eq!(display_names, vec!["Fizz"]);
assert_eq!(
display_names,
vec!["Fizz", "Angelica", "Bart", "Chucky", "Marge", "Ned", "Tommy"]
);
let active_ids: Vec<&str> = records
.iter()
.filter(|record| record.is_active)
.map(|record| record.id.as_str())
.collect();
assert_eq!(active_ids, vec!["builtin:fizz"]);
}

#[test]
Expand Down Expand Up @@ -201,7 +209,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() {

assert_eq!(
err,
"Fizz is not in My Agents. Choose it from Persona Catalog first."
"Fizz is not in My Agents. Choose it from Agent Catalog first."
);
}

Expand Down
23 changes: 21 additions & 2 deletions desktop/src/features/agents/lib/catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
getCatalogPersonas,
getCatalogSelectionState,
getLibraryPersonas,
getPersonaLabelsById,
getPersonaLibraryState,
isCatalogPersonaSelected,
Expand Down Expand Up @@ -80,7 +81,7 @@ test("getCatalogPersonas keeps chooser order stable when selection changes", ()
);
});

test("isCatalogPersonaSelected only treats active built-ins as selected", () => {
test("isCatalogPersonaSelected treats active catalog personas as selected", () => {
assert.equal(
isCatalogPersonaSelected(
createPersona("builtin:fizz", "Fizz", {
Expand All @@ -101,7 +102,7 @@ test("isCatalogPersonaSelected only treats active built-ins as selected", () =>
);
assert.equal(
isCatalogPersonaSelected(createPersona("custom:builder", "Builder")),
false,
true,
);
});

Expand Down Expand Up @@ -135,3 +136,21 @@ test("getPersonaLibraryState keeps the working library and full catalog in one p
);
assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz");
});

test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => {
const avatarUrl = "https://example.test/marge.png";
const personas = [
createPersona("builtin:marge", "Marge", {
avatarUrl,
isBuiltIn: true,
isActive: false,
}),
createPersona("custom:marge", "Marge", { avatarUrl, isActive: true }),
createPersona("custom:builder", "Builder", { isActive: true }),
];

assert.deepEqual(
getLibraryPersonas(personas).map((persona) => persona.id),
["custom:marge", "custom:builder"],
);
});
33 changes: 27 additions & 6 deletions desktop/src/features/agents/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,37 @@ export function getActivePersonas(personas: readonly AgentPersona[]) {
return personas.filter(isPersonaActive);
}

export function getCatalogPersonas(personas: readonly AgentPersona[]) {
export function getLibraryPersonas(personas: readonly AgentPersona[]) {
return getActivePersonas(personas);
}

export function isPersonaVisibleInCatalog(
persona: AgentPersona,
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id);
}

export function getCatalogPersonas(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
) {
return personas
.filter((persona) => persona.isBuiltIn)
.filter((persona) =>
isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds),
)
.sort((left, right) => left.displayName.localeCompare(right.displayName));
}

export function isCatalogPersonaSelected(persona: AgentPersona) {
return persona.isBuiltIn && persona.isActive;
return persona.isActive;
}

export function getCatalogSelectionState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): CatalogSelectionState {
const catalogPersonas = getCatalogPersonas(personas);
const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds);

return {
catalogPersonas,
Expand All @@ -52,9 +69,13 @@ export function getPersonaLabelsById(personas: readonly AgentPersona[]) {

export function getPersonaLibraryState(
personas: readonly AgentPersona[],
sharedCatalogPersonaIds: ReadonlySet<string> = new Set(),
): PersonaLibraryState {
const libraryPersonas = getActivePersonas(personas);
const { catalogPersonas } = getCatalogSelectionState(personas);
const libraryPersonas = getLibraryPersonas(personas);
const { catalogPersonas } = getCatalogSelectionState(
personas,
sharedCatalogPersonaIds,
);

return {
catalogPersonas,
Expand Down
32 changes: 31 additions & 1 deletion desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PersonaCatalogDialog } from "./PersonaCatalogDialog";
import { PersonaDialog } from "./PersonaDialog";
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
import { PersonaImportUpdateDialog } from "./PersonaImportUpdateDialog";
import { PersonaShareDialog } from "./PersonaShareDialog";
import { RelayDirectorySection } from "./RelayDirectorySection";
import { SecretRevealDialog } from "./SecretRevealDialog";
import { TeamDeleteDialog } from "./TeamDeleteDialog";
Expand Down Expand Up @@ -138,7 +139,7 @@ export function AgentsView() {
onChooseCatalog={personas.openCatalog}
onDuplicatePersona={personas.openDuplicate}
onEditPersona={personas.openEdit}
onExportPersona={personas.handleExport}
onSharePersona={personas.openShare}
onDeactivatePersona={(persona) => {
void personas.handleSetActive(persona, false, "library");
}}
Expand Down Expand Up @@ -268,6 +269,35 @@ export function AgentsView() {
persona={personas.personaToDelete}
/>
) : null}
{personas.personaToShare ? (
<PersonaShareDialog
isCatalogVisible={
personas.personaToShare.isBuiltIn ||
personas.sharedCatalogPersonaIdSet.has(personas.personaToShare.id)
}
isPending={personas.isPending}
onCatalogVisibilityChange={(visible) => {
if (personas.personaToShare) {
personas.setPersonaCatalogVisibility(
personas.personaToShare,
visible,
);
}
}}
onExport={() => {
if (personas.personaToShare) {
personas.handleExport(personas.personaToShare);
}
}}
onOpenChange={(open) => {
if (!open) {
personas.setPersonaToShare(null);
}
}}
open={personas.personaToShare !== null}
persona={personas.personaToShare}
/>
) : null}
{personas.isCatalogDialogOpen ? (
<PersonaCatalogDialog
error={
Expand Down
39 changes: 19 additions & 20 deletions desktop/src/features/agents/ui/PersonaActionsMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CopyPlus, Download, Ellipsis, Pencil, Trash2 } from "lucide-react";
import { CopyPlus, Ellipsis, Pencil, Share2, Trash2 } from "lucide-react";

import type { AgentPersona } from "@/shared/api/types";
import {
Expand All @@ -15,7 +15,7 @@ export function PersonaActionsMenu({
persona,
onDuplicate,
onEdit,
onExport,
onShare,
onDeactivate,
onDelete,
}: {
Expand All @@ -24,11 +24,12 @@ export function PersonaActionsMenu({
persona: AgentPersona;
onDuplicate: (persona: AgentPersona) => void;
onEdit: (persona: AgentPersona) => void;
onExport: (persona: AgentPersona) => void;
onShare: (persona: AgentPersona) => void;
onDeactivate: (persona: AgentPersona) => void;
onDelete: (persona: AgentPersona) => void;
}) {
const disabled = isActionPending || isPending;
const canEdit = !persona.isBuiltIn && !persona.sourceTeam;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block team persona edits from the profile panel

This only applies the sourceTeam edit guard to the new card menu. When a team-managed persona is opened from its profile panel, UserProfilePanel still passes handleEditPersona whenever the persona is owner-managed and not built-in, and the Tauri update_persona command only rejects built-ins, so in that profile-panel path users can still edit a team-managed persona's prompt/runtime/env vars despite the card menu saying it is managed by the team. Please use the same sourceTeam guard on that edit path or reject it server-side.

Useful? React with 👍 / 👎.


return (
<DropdownMenu modal={false}>
Expand All @@ -45,7 +46,11 @@ export function PersonaActionsMenu({
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{!persona.isBuiltIn ? (
<DropdownMenuItem disabled={disabled} onClick={() => onShare(persona)}>
<Share2 className="h-4 w-4" />
Share
</DropdownMenuItem>
Comment thread
klopez4212 marked this conversation as resolved.
{canEdit ? (
<DropdownMenuItem disabled={disabled} onClick={() => onEdit(persona)}>
<Pencil className="h-4 w-4" />
Edit
Expand All @@ -58,21 +63,8 @@ export function PersonaActionsMenu({
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled={disabled} onClick={() => onExport(persona)}>
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
{persona.isBuiltIn ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={disabled}
onClick={() => onDeactivate(persona)}
>
<Trash2 className="h-4 w-4" />
Remove from My Agents
</DropdownMenuItem>
) : persona.sourceTeam ? (
{persona.sourceTeam ? (
<DropdownMenuItem disabled>
<Trash2 className="h-4 w-4" />
Managed by team
Expand All @@ -81,10 +73,17 @@ export function PersonaActionsMenu({
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={disabled}
onClick={() => onDelete(persona)}
onClick={() => {
if (persona.isBuiltIn) {
onDeactivate(persona);
return;
}

onDelete(persona);
}}
>
<Trash2 className="h-4 w-4" />
Delete
Remove from My Agents
</DropdownMenuItem>
)}
</DropdownMenuContent>
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/agents/ui/PersonaAddedBy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { cn } from "@/shared/lib/cn";

type PersonaAddedByProps = {
className?: string;
};

export function PersonaAddedBy({ className }: PersonaAddedByProps) {
return (
<p className={cn("truncate text-xs leading-tight", className)}>
<span className="text-muted-foreground/55">Added by</span>
{" "}
<span className="text-muted-foreground">You</span>
</p>
);
}
Loading
Loading