diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs index cf61c0c4c..c8a7fcd3e 100644 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + readCatalogPersonaMemoryLevels, readPublishedCatalogPersonaVersions, readSharedCatalogPersonaIds, + writeCatalogPersonaMemoryLevels, writePublishedCatalogPersonaVersions, writeSharedCatalogPersonaIds, } from "./personaCatalogVisibility.ts"; @@ -57,6 +59,53 @@ test("catalog visibility persists persona ids without blocking on storage errors ); }); +test("catalog memory levels read only supported snapshot levels", () => { + const storage = { + getItem: () => + JSON.stringify({ + "custom:analyst": "none", + "custom:writer": "core", + "custom:reviewer": "everything", + "custom:invalid": "secret", + }), + }; + + assert.deepEqual(readCatalogPersonaMemoryLevels(storage), { + "custom:analyst": "none", + "custom:writer": "core", + "custom:reviewer": "everything", + }); + assert.deepEqual(readCatalogPersonaMemoryLevels(null), {}); + assert.deepEqual(readCatalogPersonaMemoryLevels({ getItem: () => "[]" }), {}); +}); + +test("catalog memory levels persist without blocking on storage errors", () => { + let storedKey = ""; + let storedValue = ""; + writeCatalogPersonaMemoryLevels( + { "custom:analyst": "core" }, + { + setItem: (key, value) => { + storedKey = key; + storedValue = value; + }, + }, + ); + + assert.equal(storedKey, "buzz-persona-catalog-memory-levels-v1"); + assert.equal(storedValue, '{"custom:analyst":"core"}'); + assert.doesNotThrow(() => + writeCatalogPersonaMemoryLevels( + { "custom:analyst": "core" }, + { + setItem: () => { + throw new Error("unavailable"); + }, + }, + ), + ); +}); + test("catalog publication versions read only string revisions", () => { const storage = { getItem: () => diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.ts b/desktop/src/features/agents/lib/personaCatalogVisibility.ts index 433df35c3..374dd06c6 100644 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.ts +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.ts @@ -1,9 +1,21 @@ +import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; + const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = "buzz-persona-catalog-visibility-v1"; const PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY = "buzz-persona-catalog-published-versions-v1"; +const PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY = + "buzz-persona-catalog-memory-levels-v1"; export type PublishedCatalogPersonaVersions = Record; +export type CatalogPersonaMemoryLevels = Record; +export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel; + +const SNAPSHOT_MEMORY_LEVELS = new Set([ + "none", + "core", + "everything", +]); function resolveStorage( storage: Pick | null | undefined, @@ -54,6 +66,52 @@ export function writeSharedCatalogPersonaIds( } } +export function readCatalogPersonaMemoryLevels( + storage?: Pick | null, +): CatalogPersonaMemoryLevels { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return {}; + + try { + const raw = targetStorage.getItem( + PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY, + ); + if (!raw) return {}; + + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, SnapshotMemoryLevel] => + typeof entry[1] === "string" && + SNAPSHOT_MEMORY_LEVELS.has(entry[1] as SnapshotMemoryLevel), + ), + ); + } catch { + return {}; + } +} + +export function writeCatalogPersonaMemoryLevels( + levels: Readonly, + storage?: Pick | null, +): void { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return; + + try { + targetStorage.setItem( + PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY, + JSON.stringify(levels), + ); + } catch { + // Catalog publication state should not block sharing. + } +} + export function readPublishedCatalogPersonaVersions( storage?: Pick | null, ): PublishedCatalogPersonaVersions { diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 60f8a7f12..30bb095f3 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -366,18 +366,18 @@ export function AgentsView() { hasCatalogUpdates={personas.hasPersonaCatalogUpdates( personas.personaToShare.persona, )} - isCatalogVisible={ - personas.personaToShare.persona.isBuiltIn || - personas.sharedCatalogPersonaIdSet.has( - personas.personaToShare.persona.id, - ) - } + catalogShareLevel={personas.getPersonaCatalogShareLevel( + personas.personaToShare.persona, + )} isPending={personas.isPending} linkedAgentPubkey={personas.personaToShare.linkedAgentPubkey} - onCatalogVisibilityChange={(visible) => { + onCatalogShareLevelChange={(shareLevel) => { const shareTarget = personas.personaToShare; if (!shareTarget) return; - personas.setPersonaCatalogVisibility(shareTarget.persona, visible); + personas.setPersonaCatalogShareLevel( + shareTarget.persona, + shareLevel, + ); }} onExport={() => { const shareTarget = personas.personaToShare; diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index ece93ccf3..4f6ae52d3 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -12,6 +12,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; +import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogVisibility"; import { useOpenDmMutation, useUpsertCachedChannel, @@ -42,7 +43,6 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; -import { Switch } from "@/shared/ui/switch"; import { formatShareRecipientName, @@ -53,11 +53,11 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + catalogShareLevel: CatalogPersonaShareLevel; hasCatalogUpdates: boolean; - isCatalogVisible: boolean; isPending: boolean; linkedAgentPubkey: string | null; - onCatalogVisibilityChange: (visible: boolean) => void; + onCatalogShareLevelChange: (shareLevel: CatalogPersonaShareLevel) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -116,6 +116,20 @@ type PendingMemoryShare = { recipientNames?: string[]; }; +function buildSnapshotShareLevels(itemLabel: "Agent" | "Team") { + return [ + { value: "none" as const, label: `${itemLabel} only` }, + { + value: "core" as const, + label: `${itemLabel} + core memory`, + }, + { + value: "everything" as const, + label: `${itemLabel} + all memories`, + }, + ]; +} + function formatRecipientAudience(names: readonly string[]): string { if (names.length === 0) return "The people you selected"; if (names.length === 1) return names[0] ?? "The person you selected"; @@ -306,17 +320,7 @@ export function SnapshotShareDialog({ const itemLabel = snapshotKind === "team" ? "team" : "agent"; const itemLabelTitle = snapshotKind === "team" ? "Team" : "Agent"; const shareLevels = React.useMemo( - () => [ - { value: "none" as const, label: `${itemLabelTitle} only` }, - { - value: "core" as const, - label: `${itemLabelTitle} + core memory`, - }, - { - value: "everything" as const, - label: `${itemLabelTitle} + all memories`, - }, - ], + () => buildSnapshotShareLevels(itemLabelTitle), [itemLabelTitle], ); const getEncodedSnapshot = React.useCallback( @@ -732,19 +736,27 @@ export function SnapshotShareDialog({ } export function PersonaShareDialog({ + catalogShareLevel, hasCatalogUpdates, - isCatalogVisible, isPending, linkedAgentPubkey, - onCatalogVisibilityChange, + onCatalogShareLevelChange, onExport, onOpenChange, onPublishCatalogUpdates, open, persona, }: PersonaShareDialogProps) { - const switchId = `persona-share-catalog-${persona.id}`; const encodeSnapshotMutation = useEncodeAgentSnapshotForSendMutation(); + const catalogShareLevels = React.useMemo( + () => [ + { value: "not-shared", label: "Not shared" }, + ...buildSnapshotShareLevels("Agent").filter( + ({ value }) => linkedAgentPubkey || value === "none", + ), + ], + [linkedAgentPubkey], + ); const encodeSnapshot = React.useCallback( async (memoryLevel: SnapshotMemoryLevel) => encodeSnapshotMutation.mutateAsync({ @@ -774,15 +786,13 @@ export function PersonaShareDialog({
- +

Share to catalog

Let anyone in this community find and use a copy of this agent.

- {isCatalogVisible && hasCatalogUpdates ? ( + {catalogShareLevel !== "not-shared" && hasCatalogUpdates ? (
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 217a96a40..8b26d8bb2 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -19,8 +19,11 @@ import { } from "@/features/agents/hooks"; import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; import { + type CatalogPersonaShareLevel, + readCatalogPersonaMemoryLevels, readPublishedCatalogPersonaVersions, readSharedCatalogPersonaIds, + writeCatalogPersonaMemoryLevels, writePublishedCatalogPersonaVersions, writeSharedCatalogPersonaIds, } from "@/features/agents/lib/personaCatalogVisibility"; @@ -96,6 +99,8 @@ export function usePersonaActions() { const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< string[] >(readSharedCatalogPersonaIds); + const [catalogPersonaMemoryLevels, setCatalogPersonaMemoryLevels] = + React.useState(readCatalogPersonaMemoryLevels); const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] = React.useState>(readPublishedCatalogPersonaVersions); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< @@ -411,12 +416,20 @@ export function usePersonaActions() { ); } - function setPersonaCatalogVisibility( + function getPersonaCatalogShareLevel( persona: AgentPersona, - visible: boolean, + ): CatalogPersonaShareLevel { + if (!sharedCatalogPersonaIdSet.has(persona.id)) return "not-shared"; + return catalogPersonaMemoryLevels[persona.id] ?? "none"; + } + + function setPersonaCatalogShareLevel( + persona: AgentPersona, + shareLevel: CatalogPersonaShareLevel, ) { if (persona.isBuiltIn) return; + const visible = shareLevel !== "not-shared"; clearFeedback("library"); setSharedCatalogPersonaIds((current) => { const next = new Set(current); @@ -430,6 +443,16 @@ export function usePersonaActions() { writeSharedCatalogPersonaIds(ids); return ids; }); + setCatalogPersonaMemoryLevels((current) => { + const next = { ...current }; + if (shareLevel !== "not-shared") { + next[persona.id] = shareLevel; + } else { + delete next[persona.id]; + } + writeCatalogPersonaMemoryLevels(next); + return next; + }); setPublishedCatalogPersonaVersions((current) => { const next = { ...current }; if (visible) { @@ -506,7 +529,8 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, - setPersonaCatalogVisibility, + getPersonaCatalogShareLevel, + setPersonaCatalogShareLevel, hasPersonaCatalogUpdates, publishPersonaCatalogUpdates, sharedCatalogPersonaIdSet, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 9d5400d21..73315be52 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -58,7 +58,10 @@ async function sharePersonaToCatalog( ) { await page.getByLabel(`Open actions for ${displayName}`).click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await page.getByTestId("persona-share-show-in-catalog").click(); + await page.getByTestId("persona-share-catalog-access").click(); + await page + .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .click(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -204,9 +207,7 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({ await page.getByLabel("Open actions for Fizz").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await expect(page.getByTestId("persona-share-catalog")).toHaveCount(0); - await expect(page.getByTestId("persona-share-show-in-catalog")).toHaveCount( - 0, - ); + await expect(page.getByTestId("persona-share-catalog-access")).toHaveCount(0); }); test("catalog empty state remains available after reopening", async ({ @@ -1317,7 +1318,7 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - const catalogToggle = page.getByTestId("persona-share-show-in-catalog"); + const catalogAccess = page.getByTestId("persona-share-catalog-access"); const shareDialog = page.getByTestId("persona-share-dialog"); const shareMainCard = shareDialog.getByTestId("persona-share-main-card"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); @@ -1346,10 +1347,17 @@ This deliberately long fenced-code example must not establish the minimum width ).toBeLessThanOrEqual( (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), ); - await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); + await expect(catalogAccess).toHaveText("Not shared"); await expect(publishCatalogUpdatesButton).toHaveCount(0); - await catalogToggle.click(); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Agent only", + ]); + await page + .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .click(); + await expect(catalogAccess).toHaveText("Agent only"); await expect(publishCatalogUpdatesButton).toHaveCount(0); await page .getByTestId("persona-share-dialog") @@ -1415,19 +1423,19 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(catalogAccess).toHaveText("Agent only"); await expect(publishCatalogUpdatesButton).toBeVisible(); - const [catalogToggleBox, publishCatalogUpdatesButtonBox] = await Promise.all([ - catalogToggle.boundingBox(), + const [catalogAccessBox, publishCatalogUpdatesButtonBox] = await Promise.all([ + catalogAccess.boundingBox(), publishCatalogUpdatesButton.boundingBox(), ]); expect( (publishCatalogUpdatesButtonBox?.x ?? 0) + (publishCatalogUpdatesButtonBox?.width ?? 0), - ).toBeLessThan(catalogToggleBox?.x ?? 0); + ).toBeLessThan(catalogAccessBox?.x ?? 0); await publishCatalogUpdatesButton.click(); await expect(publishCatalogUpdatesButton).toHaveCount(0); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(catalogAccess).toHaveText("Agent only"); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -1446,9 +1454,12 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(catalogAccess).toHaveText("Agent only"); await expect(publishCatalogUpdatesButton).toHaveCount(0); - await catalogToggle.click(); + await catalogAccess.click(); + await page + .getByRole("menuitemradio", { name: "Not shared", exact: true }) + .click(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -1511,6 +1522,7 @@ test("share access controls include the selected memories", async ({ (element) => element.getBoundingClientRect().height, ); const linkAccess = shareDialog.getByLabel("What to include in the link"); + const catalogAccess = shareDialog.getByLabel("What to share in the catalog"); const recipientField = page.getByTestId("persona-share-recipient-field"); const emptyRecipientFieldBox = await recipientField.boundingBox(); await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); @@ -1522,6 +1534,15 @@ test("share access controls include the selected memories", async ({ await expect(linkAccess).toHaveCSS("text-decoration-line", "none"); await expect(linkAccess).toHaveCSS("padding-left", "8px"); await expect(linkAccess).toHaveCSS("padding-right", "8px"); + await expect(catalogAccess).toHaveText("Not shared"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Agent only", + "Agent + core memory", + "Agent + all memories", + ]); + await page.keyboard.press("Escape"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const [linkAccessBox, copyLinkButtonBox] = await Promise.all([ linkAccess.boundingBox(),