mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add catalog update publishing
This commit is contained in:
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
readPublishedCatalogPersonaVersions,
|
||||
readSharedCatalogPersonaIds,
|
||||
writePublishedCatalogPersonaVersions,
|
||||
writeSharedCatalogPersonaIds,
|
||||
} from "./personaCatalogVisibility.ts";
|
||||
|
||||
@@ -54,3 +56,49 @@ test("catalog visibility persists persona ids without blocking on storage errors
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog publication versions read only string revisions", () => {
|
||||
const storage = {
|
||||
getItem: () =>
|
||||
JSON.stringify({
|
||||
"custom:analyst": "2026-07-22T00:00:00.000Z",
|
||||
"custom:invalid": 42,
|
||||
}),
|
||||
};
|
||||
|
||||
assert.deepEqual(readPublishedCatalogPersonaVersions(storage), {
|
||||
"custom:analyst": "2026-07-22T00:00:00.000Z",
|
||||
});
|
||||
assert.deepEqual(readPublishedCatalogPersonaVersions(null), {});
|
||||
assert.deepEqual(
|
||||
readPublishedCatalogPersonaVersions({ getItem: () => "[]" }),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog publication versions persist without blocking on storage errors", () => {
|
||||
let storedKey = "";
|
||||
let storedValue = "";
|
||||
writePublishedCatalogPersonaVersions(
|
||||
{ "custom:analyst": "2026-07-22T00:00:00.000Z" },
|
||||
{
|
||||
setItem: (key, value) => {
|
||||
storedKey = key;
|
||||
storedValue = value;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(storedKey, "buzz-persona-catalog-published-versions-v1");
|
||||
assert.equal(storedValue, '{"custom:analyst":"2026-07-22T00:00:00.000Z"}');
|
||||
assert.doesNotThrow(() =>
|
||||
writePublishedCatalogPersonaVersions(
|
||||
{ "custom:analyst": "2026-07-22T00:00:00.000Z" },
|
||||
{
|
||||
setItem: () => {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY =
|
||||
"buzz-persona-catalog-visibility-v1";
|
||||
const PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY =
|
||||
"buzz-persona-catalog-published-versions-v1";
|
||||
|
||||
export type PublishedCatalogPersonaVersions = Record<string, string>;
|
||||
|
||||
function resolveStorage<T extends "getItem" | "setItem">(
|
||||
storage: Pick<Storage, T> | null | undefined,
|
||||
@@ -49,3 +53,47 @@ export function writeSharedCatalogPersonaIds(
|
||||
// Catalog visibility is a convenience setting and should not block sharing.
|
||||
}
|
||||
}
|
||||
|
||||
export function readPublishedCatalogPersonaVersions(
|
||||
storage?: Pick<Storage, "getItem"> | null,
|
||||
): PublishedCatalogPersonaVersions {
|
||||
const targetStorage = resolveStorage(storage);
|
||||
if (!targetStorage) return {};
|
||||
|
||||
try {
|
||||
const raw = targetStorage.getItem(
|
||||
PERSONA_CATALOG_PUBLISHED_VERSIONS_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, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writePublishedCatalogPersonaVersions(
|
||||
versions: Readonly<PublishedCatalogPersonaVersions>,
|
||||
storage?: Pick<Storage, "setItem"> | null,
|
||||
): void {
|
||||
const targetStorage = resolveStorage(storage);
|
||||
if (!targetStorage) return;
|
||||
|
||||
try {
|
||||
targetStorage.setItem(
|
||||
PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY,
|
||||
JSON.stringify(versions),
|
||||
);
|
||||
} catch {
|
||||
// Catalog publication state should not block sharing.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
AUTO_MODEL_DROPDOWN_VALUE,
|
||||
AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
|
||||
buildPersonaRuntimeDropdownOptions,
|
||||
CUSTOM_PROVIDER_DROPDOWN_VALUE,
|
||||
computeLocalModeGate,
|
||||
formatRuntimeOptionLabel,
|
||||
getDefaultPersonaRuntime,
|
||||
getPersonaModelOptions,
|
||||
getPersonaProviderOptions,
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
PERSONA_LABEL_OPTIONAL_CLASS,
|
||||
shouldClearKnownModelForSelectionScope,
|
||||
sortPersonaRuntimes,
|
||||
} from "./agentConfigOptions";
|
||||
import { RequiredFieldLabel } from "./agentConfigControls";
|
||||
import {
|
||||
@@ -97,13 +96,20 @@ type AgentDefinitionDialogProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (
|
||||
input: CreatePersonaInput | UpdatePersonaInput,
|
||||
options: AgentDefinitionSubmitOptions,
|
||||
) => Promise<unknown>;
|
||||
/** Offers publishing alongside Save after a shared agent has been edited. */
|
||||
showPublishUpdatesOption?: boolean;
|
||||
/** Rendered below the form fields in create mode only ("Where to run"). */
|
||||
createRunSection?: React.ReactNode;
|
||||
/** Extra create-mode submit gate (e.g. incomplete provider config). */
|
||||
createSubmitBlocked?: boolean;
|
||||
};
|
||||
|
||||
export type AgentDefinitionSubmitOptions = {
|
||||
publishCatalogUpdates: boolean;
|
||||
};
|
||||
|
||||
const ADVANCED_FIELDS_MOTION_TRANSITION = {
|
||||
duration: 0.18,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
@@ -121,6 +127,7 @@ export function AgentDefinitionDialog({
|
||||
runtimesLoading = false,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
showPublishUpdatesOption = false,
|
||||
createRunSection,
|
||||
createSubmitBlocked = false,
|
||||
}: AgentDefinitionDialogProps) {
|
||||
@@ -158,6 +165,9 @@ export function AgentDefinitionDialog({
|
||||
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
|
||||
const [isAvatarUploadPending, setIsAvatarUploadPending] =
|
||||
React.useState(false);
|
||||
const [hasUserChanges, setHasUserChanges] = React.useState(false);
|
||||
const [publishUpdatesChecked, setPublishUpdatesChecked] =
|
||||
React.useState(false);
|
||||
const {
|
||||
globalConfig,
|
||||
inheritedDefaults: {
|
||||
@@ -213,6 +223,8 @@ export function AgentDefinitionDialog({
|
||||
// sufficient reason to auto-open.
|
||||
setShowAdvancedFields(false);
|
||||
setIsAvatarUploadPending(false);
|
||||
setHasUserChanges(false);
|
||||
setPublishUpdatesChecked(false);
|
||||
isRuntimeAutoSeededRef.current = false;
|
||||
hasSeededForOpenRef.current = false;
|
||||
}, [initialValues, open]);
|
||||
@@ -258,6 +270,8 @@ export function AgentDefinitionDialog({
|
||||
behaviorSeedRef.current = emptyPersonaBehaviorDraft;
|
||||
setShowAdvancedFields(false);
|
||||
setIsAvatarUploadPending(false);
|
||||
setHasUserChanges(false);
|
||||
setPublishUpdatesChecked(false);
|
||||
// isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the
|
||||
// [initialValues, open] effect resets both when the dialog re-opens.
|
||||
}
|
||||
@@ -309,14 +323,20 @@ export function AgentDefinitionDialog({
|
||||
};
|
||||
|
||||
if ("id" in initialValues) {
|
||||
await onSubmit({
|
||||
id: initialValues.id,
|
||||
...baseInput,
|
||||
});
|
||||
await onSubmit(
|
||||
{
|
||||
id: initialValues.id,
|
||||
...baseInput,
|
||||
},
|
||||
{
|
||||
publishCatalogUpdates:
|
||||
showPublishUpdatesOption && hasUserChanges && publishUpdatesChecked,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(baseInput);
|
||||
await onSubmit(baseInput, { publishCatalogUpdates: false });
|
||||
}
|
||||
|
||||
function handleSubmitForm(event: React.FormEvent<HTMLFormElement>) {
|
||||
@@ -342,6 +362,7 @@ export function AgentDefinitionDialog({
|
||||
const { data: runtimeFileConfig, isLoading: fileConfigLoading } =
|
||||
useRuntimeFileConfigQuery(runtime, { enabled: open });
|
||||
function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) {
|
||||
setHasUserChanges(true);
|
||||
setAiConfigurationMode(nextMode);
|
||||
setIsCustomProviderEditing(false);
|
||||
setIsCustomModelEditing(false);
|
||||
@@ -552,41 +573,14 @@ export function AgentDefinitionDialog({
|
||||
const showCustomProviderInput =
|
||||
llmProviderFieldVisible && isCustomProviderEditing;
|
||||
const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE;
|
||||
const sortedRuntimes = React.useMemo(
|
||||
() => sortPersonaRuntimes(runtimes),
|
||||
[runtimes],
|
||||
);
|
||||
const blankRuntimeOptionLabel = runtimesLoading
|
||||
? "Loading harnesses..."
|
||||
: isCreateMode
|
||||
? "Choose a harness"
|
||||
: "No preference (use app default)";
|
||||
const runtimeDropdownOptions: PersonaDropdownOption[] = [
|
||||
...(!isCreateMode
|
||||
? [
|
||||
{
|
||||
label: blankRuntimeOptionLabel,
|
||||
value: NO_RUNTIME_DROPDOWN_VALUE,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...sortedRuntimes.map((candidate) => ({
|
||||
disabled: isCreateMode && candidate.availability !== "available",
|
||||
label: `${formatRuntimeOptionLabel(candidate)}${
|
||||
isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : ""
|
||||
}`,
|
||||
value: candidate.id,
|
||||
})),
|
||||
];
|
||||
if (
|
||||
runtime.trim().length > 0 &&
|
||||
!runtimeDropdownOptions.some((option) => option.value === runtime)
|
||||
) {
|
||||
runtimeDropdownOptions.push({
|
||||
label: `${runtime.trim()} (current)`,
|
||||
value: runtime.trim(),
|
||||
const { blankRuntimeOptionLabel, runtimeDropdownOptions } =
|
||||
buildPersonaRuntimeDropdownOptions({
|
||||
defaultRuntimeId: defaultRuntime?.id,
|
||||
isCreateMode,
|
||||
runtime,
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
});
|
||||
}
|
||||
const providerDropdownOptions: PersonaDropdownOption[] = [
|
||||
...providerOptions
|
||||
.filter((option) => option.id.trim().length > 0)
|
||||
@@ -673,6 +667,7 @@ export function AgentDefinitionDialog({
|
||||
}
|
||||
|
||||
function handleRuntimeDropdownChange(nextValue: string) {
|
||||
setHasUserChanges(true);
|
||||
const nextRuntime =
|
||||
nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue;
|
||||
// The user made an explicit choice — no longer auto-seeded.
|
||||
@@ -691,6 +686,7 @@ export function AgentDefinitionDialog({
|
||||
}
|
||||
|
||||
function handleProviderDropdownChange(nextValue: string) {
|
||||
setHasUserChanges(true);
|
||||
const nextProvider =
|
||||
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
|
||||
if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") {
|
||||
@@ -708,6 +704,7 @@ export function AgentDefinitionDialog({
|
||||
}
|
||||
|
||||
function handleModelDropdownChange(nextValue: string) {
|
||||
setHasUserChanges(true);
|
||||
applySelection(
|
||||
selectionOnModelDropdownChange(selection, {
|
||||
nextValue,
|
||||
@@ -739,6 +736,9 @@ export function AgentDefinitionDialog({
|
||||
isAvatarUploadPending={isAvatarUploadPending}
|
||||
isPending={isPending}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPublishUpdatesCheckedChange={setPublishUpdatesChecked}
|
||||
publishUpdatesChecked={publishUpdatesChecked}
|
||||
showPublishUpdates={showPublishUpdatesOption && hasUserChanges}
|
||||
submitBlockReason={displayName.trim() ? submitBlockReason : null}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
@@ -747,15 +747,22 @@ export function AgentDefinitionDialog({
|
||||
<form
|
||||
className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]"
|
||||
id="persona-dialog-form"
|
||||
onChangeCapture={() => setHasUserChanges(true)}
|
||||
onSubmit={handleSubmitForm}
|
||||
>
|
||||
<AgentCreationPreview
|
||||
avatarUrl={previewAvatarUrl}
|
||||
disabled={isPending || isAvatarUploadPending}
|
||||
label={previewLabel}
|
||||
onClearAvatar={() => setAvatarUrl("")}
|
||||
onClearAvatar={() => {
|
||||
setHasUserChanges(true);
|
||||
setAvatarUrl("");
|
||||
}}
|
||||
onUploadPendingChange={setIsAvatarUploadPending}
|
||||
onSelectAvatar={setAvatarUrl}
|
||||
onSelectAvatar={(nextAvatarUrl) => {
|
||||
setHasUserChanges(true);
|
||||
setAvatarUrl(nextAvatarUrl);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-5">
|
||||
@@ -985,7 +992,10 @@ export function AgentDefinitionDialog({
|
||||
model={model}
|
||||
modelTuningRuntimeId={runtime}
|
||||
namePoolText={namePoolText}
|
||||
onBehaviorDraftChange={setBehaviorDraft}
|
||||
onBehaviorDraftChange={(nextBehaviorDraft) => {
|
||||
setHasUserChanges(true);
|
||||
setBehaviorDraft(nextBehaviorDraft);
|
||||
}}
|
||||
onEnvVarsChange={setEnvVars}
|
||||
onNamePoolTextChange={setNamePoolText}
|
||||
provider={effectiveProvider}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
|
||||
type AgentDefinitionDialogFooterProps = {
|
||||
canSubmit: boolean;
|
||||
isAvatarUploadPending: boolean;
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
onPublishUpdatesCheckedChange: (checked: boolean) => void;
|
||||
publishUpdatesChecked: boolean;
|
||||
showPublishUpdates: boolean;
|
||||
submitBlockReason: string | null;
|
||||
submitLabel: string;
|
||||
};
|
||||
@@ -14,6 +18,9 @@ export function AgentDefinitionDialogFooter({
|
||||
isAvatarUploadPending,
|
||||
isPending,
|
||||
onCancel,
|
||||
onPublishUpdatesCheckedChange,
|
||||
publishUpdatesChecked,
|
||||
showPublishUpdates,
|
||||
submitBlockReason,
|
||||
submitLabel,
|
||||
}: AgentDefinitionDialogFooterProps) {
|
||||
@@ -51,6 +58,23 @@ export function AgentDefinitionDialogFooter({
|
||||
? "Uploading..."
|
||||
: submitLabel}
|
||||
</Button>
|
||||
{showPublishUpdates ? (
|
||||
<label
|
||||
className="ml-1 flex cursor-pointer items-center gap-2 text-sm font-medium text-foreground"
|
||||
htmlFor="persona-dialog-publish-updates"
|
||||
>
|
||||
<Checkbox
|
||||
checked={publishUpdatesChecked}
|
||||
data-testid="persona-dialog-publish-updates"
|
||||
disabled={isPending || isAvatarUploadPending}
|
||||
id="persona-dialog-publish-updates"
|
||||
onCheckedChange={(checked) =>
|
||||
onPublishUpdatesCheckedChange(checked === true)
|
||||
}
|
||||
/>
|
||||
<span>Publish updates</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,10 @@ import type { AgentCreateIntent } from "./agentCreateIntent";
|
||||
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
|
||||
import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog";
|
||||
import { createPersonaDialogState } from "./personaDialogState";
|
||||
import { AgentDefinitionDialog } from "./AgentDefinitionDialog";
|
||||
import {
|
||||
AgentDefinitionDialog,
|
||||
type AgentDefinitionSubmitOptions,
|
||||
} from "./AgentDefinitionDialog";
|
||||
import { WhereToRunSection } from "./WhereToRunSection";
|
||||
import {
|
||||
canSubmitWhereToRun,
|
||||
@@ -64,7 +67,9 @@ type AgentDialogDefinitionEditProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (
|
||||
input: CreatePersonaInput | UpdatePersonaInput,
|
||||
options: AgentDefinitionSubmitOptions,
|
||||
) => Promise<unknown>;
|
||||
showPublishUpdatesOption?: boolean;
|
||||
};
|
||||
|
||||
type AgentDialogProps =
|
||||
|
||||
@@ -322,8 +322,22 @@ export function AgentsView() {
|
||||
personas.setPersonaDialogState(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={personas.handleSubmit}
|
||||
onSubmit={(input, options) =>
|
||||
personas.handleSubmit(
|
||||
input,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
options,
|
||||
)
|
||||
}
|
||||
open={personas.personaDialogState !== null}
|
||||
showPublishUpdatesOption={
|
||||
"id" in personas.personaDialogState.initialValues &&
|
||||
personas.sharedCatalogPersonaIdSet.has(
|
||||
personas.personaDialogState.initialValues.id,
|
||||
)
|
||||
}
|
||||
submitLabel={personas.personaDialogState.submitLabel}
|
||||
title={personas.personaDialogState.title}
|
||||
/>
|
||||
@@ -349,6 +363,9 @@ export function AgentsView() {
|
||||
) : null}
|
||||
{personas.personaToShare ? (
|
||||
<PersonaShareDialog
|
||||
hasCatalogUpdates={personas.hasPersonaCatalogUpdates(
|
||||
personas.personaToShare.persona,
|
||||
)}
|
||||
isCatalogVisible={
|
||||
personas.personaToShare.persona.isBuiltIn ||
|
||||
personas.sharedCatalogPersonaIdSet.has(
|
||||
@@ -373,6 +390,11 @@ export function AgentsView() {
|
||||
personas.setPersonaToShare(null);
|
||||
}
|
||||
}}
|
||||
onPublishCatalogUpdates={() => {
|
||||
const shareTarget = personas.personaToShare;
|
||||
if (!shareTarget) return;
|
||||
personas.publishPersonaCatalogUpdates(shareTarget.persona);
|
||||
}}
|
||||
open={personas.personaToShare !== null}
|
||||
persona={personas.personaToShare.persona}
|
||||
/>
|
||||
|
||||
@@ -53,6 +53,7 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng";
|
||||
import { useSnapshotSendController } from "./useSnapshotSendController";
|
||||
|
||||
type PersonaShareDialogProps = {
|
||||
hasCatalogUpdates: boolean;
|
||||
isCatalogVisible: boolean;
|
||||
isPending: boolean;
|
||||
linkedAgentPubkey: string | null;
|
||||
@@ -60,6 +61,7 @@ type PersonaShareDialogProps = {
|
||||
onExport: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
onPublishCatalogUpdates: () => void;
|
||||
persona: AgentPersona;
|
||||
};
|
||||
|
||||
@@ -722,12 +724,14 @@ export function SnapshotShareDialog({
|
||||
}
|
||||
|
||||
export function PersonaShareDialog({
|
||||
hasCatalogUpdates,
|
||||
isCatalogVisible,
|
||||
isPending,
|
||||
linkedAgentPubkey,
|
||||
onCatalogVisibilityChange,
|
||||
onExport,
|
||||
onOpenChange,
|
||||
onPublishCatalogUpdates,
|
||||
open,
|
||||
persona,
|
||||
}: PersonaShareDialogProps) {
|
||||
@@ -767,13 +771,26 @@ export function PersonaShareDialog({
|
||||
Let anyone on this relay find and use this agent.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isCatalogVisible}
|
||||
data-testid="persona-share-show-in-catalog"
|
||||
disabled={isPending}
|
||||
id={switchId}
|
||||
onCheckedChange={onCatalogVisibilityChange}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Switch
|
||||
checked={isCatalogVisible}
|
||||
data-testid="persona-share-show-in-catalog"
|
||||
disabled={isPending}
|
||||
id={switchId}
|
||||
onCheckedChange={onCatalogVisibilityChange}
|
||||
/>
|
||||
{isCatalogVisible && hasCatalogUpdates ? (
|
||||
<Button
|
||||
data-testid="persona-share-publish-catalog-updates"
|
||||
disabled={isPending}
|
||||
onClick={onPublishCatalogUpdates}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Publish updates
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -426,6 +426,57 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) {
|
||||
return `${runtime.label}${suffix}`;
|
||||
}
|
||||
|
||||
export function buildPersonaRuntimeDropdownOptions({
|
||||
defaultRuntimeId,
|
||||
isCreateMode,
|
||||
runtime,
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
}: {
|
||||
defaultRuntimeId?: string;
|
||||
isCreateMode: boolean;
|
||||
runtime: string;
|
||||
runtimes: AcpRuntimeCatalogEntry[];
|
||||
runtimesLoading: boolean;
|
||||
}): {
|
||||
blankRuntimeOptionLabel: string;
|
||||
runtimeDropdownOptions: PersonaDropdownOption[];
|
||||
} {
|
||||
const blankRuntimeOptionLabel = runtimesLoading
|
||||
? "Loading harnesses..."
|
||||
: isCreateMode
|
||||
? "Choose a harness"
|
||||
: "No preference (use app default)";
|
||||
const runtimeDropdownOptions: PersonaDropdownOption[] = [
|
||||
...(!isCreateMode
|
||||
? [
|
||||
{
|
||||
label: blankRuntimeOptionLabel,
|
||||
value: NO_RUNTIME_DROPDOWN_VALUE,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...sortPersonaRuntimes(runtimes).map((candidate) => ({
|
||||
disabled: isCreateMode && candidate.availability !== "available",
|
||||
label: `${formatRuntimeOptionLabel(candidate)}${
|
||||
isCreateMode && candidate.id === defaultRuntimeId ? " (default)" : ""
|
||||
}`,
|
||||
value: candidate.id,
|
||||
})),
|
||||
];
|
||||
const currentRuntime = runtime.trim();
|
||||
if (
|
||||
currentRuntime.length > 0 &&
|
||||
!runtimeDropdownOptions.some((option) => option.value === currentRuntime)
|
||||
) {
|
||||
runtimeDropdownOptions.push({
|
||||
label: `${currentRuntime} (current)`,
|
||||
value: currentRuntime,
|
||||
});
|
||||
}
|
||||
return { blankRuntimeOptionLabel, runtimeDropdownOptions };
|
||||
}
|
||||
|
||||
function runtimeAvailabilitySortRank(
|
||||
availability: AcpRuntimeCatalogEntry["availability"],
|
||||
) {
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
} from "@/features/agents/hooks";
|
||||
import { getPersonaLibraryState } from "@/features/agents/lib/catalog";
|
||||
import {
|
||||
readPublishedCatalogPersonaVersions,
|
||||
readSharedCatalogPersonaIds,
|
||||
writePublishedCatalogPersonaVersions,
|
||||
writeSharedCatalogPersonaIds,
|
||||
} from "@/features/agents/lib/personaCatalogVisibility";
|
||||
import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment";
|
||||
@@ -94,6 +96,8 @@ export function usePersonaActions() {
|
||||
const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState<
|
||||
string[]
|
||||
>(readSharedCatalogPersonaIds);
|
||||
const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] =
|
||||
React.useState<Record<string, string>>(readPublishedCatalogPersonaVersions);
|
||||
const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -137,6 +141,7 @@ export function usePersonaActions() {
|
||||
intent?: AgentCreateIntent,
|
||||
backendIntent?: BackendIntent | null,
|
||||
targetChannel?: Pick<Channel, "id" | "name"> | null,
|
||||
options?: { publishCatalogUpdates?: boolean },
|
||||
): Promise<boolean> {
|
||||
if (isPersonaSubmitPending) {
|
||||
return false;
|
||||
@@ -146,7 +151,10 @@ export function usePersonaActions() {
|
||||
setIsPersonaSubmitPending(true);
|
||||
try {
|
||||
if ("id" in input) {
|
||||
await updatePersonaMutation.mutateAsync(input);
|
||||
const updatedPersona = await updatePersonaMutation.mutateAsync(input);
|
||||
if (options?.publishCatalogUpdates) {
|
||||
publishPersonaCatalogUpdates(updatedPersona);
|
||||
}
|
||||
setPersonaNoticeMessage(`Updated ${input.displayName}.`);
|
||||
} else {
|
||||
const runtime = availableRuntimes.find(
|
||||
@@ -354,6 +362,16 @@ export function usePersonaActions() {
|
||||
linkedAgent: ManagedAgent | undefined,
|
||||
) {
|
||||
clearFeedback("library");
|
||||
if (
|
||||
sharedCatalogPersonaIdSet.has(persona.id) &&
|
||||
publishedCatalogPersonaVersions[persona.id] === undefined
|
||||
) {
|
||||
setPublishedCatalogPersonaVersions((current) => {
|
||||
const next = { ...current, [persona.id]: persona.updatedAt };
|
||||
writePublishedCatalogPersonaVersions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setPersonaToShare({
|
||||
persona,
|
||||
linkedAgentPubkey: linkedAgent?.pubkey ?? null,
|
||||
@@ -412,6 +430,35 @@ export function usePersonaActions() {
|
||||
writeSharedCatalogPersonaIds(ids);
|
||||
return ids;
|
||||
});
|
||||
setPublishedCatalogPersonaVersions((current) => {
|
||||
const next = { ...current };
|
||||
if (visible) {
|
||||
next[persona.id] = persona.updatedAt;
|
||||
} else {
|
||||
delete next[persona.id];
|
||||
}
|
||||
writePublishedCatalogPersonaVersions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function hasPersonaCatalogUpdates(persona: AgentPersona) {
|
||||
const publishedVersion = publishedCatalogPersonaVersions[persona.id];
|
||||
return (
|
||||
sharedCatalogPersonaIdSet.has(persona.id) &&
|
||||
publishedVersion !== undefined &&
|
||||
publishedVersion !== persona.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function publishPersonaCatalogUpdates(persona: AgentPersona) {
|
||||
if (persona.isBuiltIn || !sharedCatalogPersonaIdSet.has(persona.id)) return;
|
||||
|
||||
setPublishedCatalogPersonaVersions((current) => {
|
||||
const next = { ...current, [persona.id]: persona.updatedAt };
|
||||
writePublishedCatalogPersonaVersions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const isPending =
|
||||
@@ -460,6 +507,8 @@ export function usePersonaActions() {
|
||||
setPersonaToExportSnapshot,
|
||||
handleExportSnapshot,
|
||||
setPersonaCatalogVisibility,
|
||||
hasPersonaCatalogUpdates,
|
||||
publishPersonaCatalogUpdates,
|
||||
sharedCatalogPersonaIdSet,
|
||||
clearFeedback,
|
||||
snapshotImportState,
|
||||
|
||||
@@ -1288,6 +1288,11 @@ test("custom personas share with people and keep export separate", async ({
|
||||
test("custom personas can be shared to the relay catalog", async ({ page }) => {
|
||||
const personaId = "custom:catalog-analyst";
|
||||
await installMockBridge(page, {
|
||||
globalAgentConfig: {
|
||||
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
},
|
||||
personas: [
|
||||
{
|
||||
id: personaId,
|
||||
@@ -1319,9 +1324,14 @@ 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 publishCatalogUpdatesButton = page.getByTestId(
|
||||
"persona-share-publish-catalog-updates",
|
||||
);
|
||||
await expect(catalogToggle).toHaveAttribute("aria-checked", "false");
|
||||
await expect(publishCatalogUpdatesButton).toHaveCount(0);
|
||||
await catalogToggle.click();
|
||||
await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
|
||||
await expect(publishCatalogUpdatesButton).toHaveCount(0);
|
||||
await page
|
||||
.getByTestId("persona-share-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
@@ -1347,9 +1357,65 @@ This deliberately long fenced-code example must not establish the minimum width
|
||||
).toBeLessThanOrEqual(1);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByLabel("Open actions for Catalog Analyst").click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
const editDialog = page.getByTestId("persona-dialog");
|
||||
const publishUpdatesCheckbox = editDialog.getByTestId(
|
||||
"persona-dialog-publish-updates",
|
||||
);
|
||||
await expect(publishUpdatesCheckbox).toHaveCount(0);
|
||||
await editDialog
|
||||
.getByLabel("Agent instructions")
|
||||
.fill("Review the latest catalog changes.");
|
||||
await expect(publishUpdatesCheckbox).toBeVisible();
|
||||
await expect(publishUpdatesCheckbox).toHaveAttribute(
|
||||
"data-state",
|
||||
"unchecked",
|
||||
);
|
||||
const [saveButtonBox, publishUpdatesCheckboxBox] = await Promise.all([
|
||||
editDialog.getByRole("button", { name: "Save changes" }).boundingBox(),
|
||||
publishUpdatesCheckbox.boundingBox(),
|
||||
]);
|
||||
expect(publishUpdatesCheckboxBox?.x ?? 0).toBeGreaterThan(
|
||||
(saveButtonBox?.x ?? 0) + (saveButtonBox?.width ?? 0),
|
||||
);
|
||||
await editDialog.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(editDialog).toHaveCount(0);
|
||||
|
||||
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(publishCatalogUpdatesButton).toBeVisible();
|
||||
const [catalogToggleBox, publishCatalogUpdatesButtonBox] = await Promise.all([
|
||||
catalogToggle.boundingBox(),
|
||||
publishCatalogUpdatesButton.boundingBox(),
|
||||
]);
|
||||
expect(publishCatalogUpdatesButtonBox?.x ?? 0).toBeGreaterThan(
|
||||
(catalogToggleBox?.x ?? 0) + (catalogToggleBox?.width ?? 0),
|
||||
);
|
||||
await publishCatalogUpdatesButton.click();
|
||||
await expect(publishCatalogUpdatesButton).toHaveCount(0);
|
||||
await expect(catalogToggle).toHaveAttribute("aria-checked", "true");
|
||||
await page
|
||||
.getByTestId("persona-share-dialog")
|
||||
.getByRole("button", { name: "Close" })
|
||||
.click();
|
||||
|
||||
await page.getByLabel("Open actions for Catalog Analyst").click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
await editDialog
|
||||
.getByLabel("Agent instructions")
|
||||
.fill("Review and publish the latest catalog changes.");
|
||||
await expect(publishUpdatesCheckbox).toBeVisible();
|
||||
await publishUpdatesCheckbox.click();
|
||||
await expect(publishUpdatesCheckbox).toHaveAttribute("data-state", "checked");
|
||||
await editDialog.getByRole("button", { name: "Save changes" }).click();
|
||||
await expect(editDialog).toHaveCount(0);
|
||||
|
||||
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(publishCatalogUpdatesButton).toHaveCount(0);
|
||||
await catalogToggle.click();
|
||||
await page
|
||||
.getByTestId("persona-share-dialog")
|
||||
|
||||
Reference in New Issue
Block a user