mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Use catalog sharing access menu
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user