Use catalog sharing access menu

This commit is contained in:
kenny lopez
2026-07-23 12:14:54 -07:00
parent a1570f1817
commit 20afa183ae
6 changed files with 219 additions and 52 deletions
@@ -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: () =>
@@ -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<string, string>;
export type CatalogPersonaMemoryLevels = Record<string, SnapshotMemoryLevel>;
export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel;
const SNAPSHOT_MEMORY_LEVELS = new Set<SnapshotMemoryLevel>([
"none",
"core",
"everything",
]);
function resolveStorage<T extends "getItem" | "setItem">(
storage: Pick<Storage, T> | null | undefined,
@@ -54,6 +66,52 @@ export function writeSharedCatalogPersonaIds(
}
}
export function readCatalogPersonaMemoryLevels(
storage?: Pick<Storage, "getItem"> | 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<CatalogPersonaMemoryLevels>,
storage?: Pick<Storage, "setItem"> | 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<Storage, "getItem"> | null,
): PublishedCatalogPersonaVersions {
@@ -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;
@@ -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({
<BookUser className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<label className="text-sm font-medium" htmlFor={switchId}>
Share to catalog
</label>
<h3 className="text-sm font-medium">Share to catalog</h3>
<p className="text-xs text-secondary-foreground/75">
Let anyone in this community find and use a copy of this agent.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{isCatalogVisible && hasCatalogUpdates ? (
{catalogShareLevel !== "not-shared" && hasCatalogUpdates ? (
<Button
data-testid="persona-share-publish-catalog-updates"
disabled={isPending}
@@ -793,12 +803,17 @@ export function PersonaShareDialog({
Publish updates
</Button>
) : null}
<Switch
checked={isCatalogVisible}
data-testid="persona-share-show-in-catalog"
<SnapshotOptionMenu
ariaLabel="What to share in the catalog"
disabled={isPending}
id={switchId}
onCheckedChange={onCatalogVisibilityChange}
onValueChange={(nextValue) =>
onCatalogShareLevelChange(
nextValue as CatalogPersonaShareLevel,
)
}
options={catalogShareLevels}
testId="persona-share-catalog-access"
value={catalogShareLevel}
/>
</div>
</section>
@@ -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<Record<string, string>>(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,
+36 -15
View File
@@ -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(),