Files
buzz/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
Wes f716eef437 fix(desktop): enforce shared agent access across devices (#6086)
## Summary

- discover shared managed agents from authenticated relay directory
records instead of treating channel membership as sufficient proof
- publish and refresh access-policy changes immediately so running
clients converge across machines without a restart or five-minute poll
- route profile edits through the exact managed instance and
stop/restart runtimes around access changes so unrelated edits cannot
silently widen access
- keep mention send-time revalidation and Block owner-only build
enforcement fail closed
- explain invalid custom provider/model configuration instead of leaving
Save silently disabled

### Related issue

Fixes #3204

### Known residuals

- a brand-new remote agent's first policy record can wait for the
bounded directory poll when no authenticated directory coordinate exists
yet; send-time mention revalidation remains fail closed
- a failed remote-provider policy redeploy is recorded but cannot
undeploy the older provider instance until the provider protocol gains
the destructor tracked by #5570

### Testing

- full Desktop unit suite: 4,961 tests passed
- focused profile editor Playwright workflow passed, including Customize
access edits and prompt-only edits after tightening an instance
- Desktop TypeScript, Biome formatting, file-size ratchet, Tauri checks,
and pre-push suites passed
- independently reviewed for authenticated directory trust, live
subscription teardown, runtime revocation ordering, fail-open edit
paths, and per-agent provider deployment serialization

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
Co-authored-by: diegorumo <diegorumo@gmail.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
2026-08-17 09:51:00 -07:00

1034 lines
36 KiB
TypeScript

import * as React from "react";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type {
AcpRuntimeCatalogEntry,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { AgentCreationPreview } from "./AgentCreationPreview";
import { PersonaDropdownField } from "./PersonaDropdownField";
import type { EnvVarsValue } from "./EnvVarsEditor";
import { PersonaAdvancedFields } from "./PersonaAdvancedFields";
import { PersonaModelField } from "./PersonaModelField";
import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning";
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
import {
canSubmitPersonaDialog,
formatPersonaNamePoolText,
parsePersonaNamePoolText,
} from "./personaDialogState";
import { hasText } from "./personaDialogEnvVars";
import {
behaviorForSubmit,
draftFromBehavior,
emptyPersonaBehaviorDraft,
personaBehaviorDraftValid,
} from "./personaBehaviorDraft";
import {
ADVANCED_FIELDS_MOTION_TRANSITION,
AUTO_MODEL_DROPDOWN_VALUE,
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
buildPersonaRuntimeDropdownOptions,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
computeLocalModeGate,
formatRuntimeOptionLabel,
getDefaultPersonaRuntime,
getPersonaModelOptions,
getPersonaProviderOptions,
getProviderApiKeyLabel,
getRuntimePersonaModelOptions,
NO_RUNTIME_DROPDOWN_VALUE,
runtimeSupportsLlmProviderSelection,
type PersonaDropdownOption,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
shouldClearKnownModelForSelectionScope,
} from "./agentConfigOptions";
import { RequiredFieldLabel } from "./agentConfigControls";
import {
modelDropdownOptions as buildModelDropdownOptions,
relayMeshModelPickerState,
} from "./relayMeshModelPicker";
import {
selectionOnModelDropdownChange,
selectionOnProviderDropdownChange,
selectionOnRuntimeChange,
type RuntimeModelProviderSelection,
} from "./runtimeModelProviderSelection";
import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { AgentHarnessField } from "./AgentHarnessField";
import {
AgentAiConfigurationModeField,
AgentCreateAiDefaultsSummary,
type AgentAiConfigurationMode,
} from "./AgentAiConfigurationMode";
import {
agentAiConfigurationModeSatisfied,
agentAiConfigurationPairForMode,
initialAgentAiConfigurationMode,
} from "./agentAiConfigurationPolicy";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter";
import { AgentDefinitionDialogShell } from "./AgentDefinitionDialogShell";
import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
import {
ADD_CUSTOM_HARNESS_OPTION,
runtimeDropdownAction,
usePendingHarnessSelection,
} from "./addCustomHarness";
type AgentDefinitionDialogProps = {
open: boolean;
embedded?: boolean;
title: string;
description: string;
submitLabel: string;
initialValues: CreatePersonaInput | UpdatePersonaInput | null;
error: Error | null;
isPending: boolean;
runtimes: AcpRuntimeCatalogEntry[];
runtimeCatalogStatus?: "loading" | "ready" | "error";
onDirtyChange?: (dirty: boolean) => void;
onOpenChange: (open: boolean) => void;
onSubmit: (
input: CreatePersonaInput | UpdatePersonaInput,
options: AgentDefinitionSubmitOptions,
) => Promise<unknown>;
/** Publishes saved changes when the edited agent is shared in the catalog. */
publishCatalogUpdatesOnSave?: boolean;
createRunSection?: React.ReactNode;
/** Extra create-mode submit gate (e.g. incomplete provider config). */
createSubmitBlocked?: boolean;
};
export type AgentDefinitionSubmitOptions = {
publishCatalogUpdates: boolean;
};
export function AgentDefinitionDialog({
open,
embedded = false,
title,
description,
submitLabel,
initialValues,
error,
isPending,
runtimes,
runtimeCatalogStatus = "ready" as const,
onDirtyChange,
onOpenChange,
onSubmit,
publishCatalogUpdatesOnSave = false,
createRunSection,
createSubmitBlocked = false,
}: AgentDefinitionDialogProps) {
const runtimesLoading = runtimeCatalogStatus === "loading";
const [displayName, setDisplayName] = React.useState("");
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const [avatarUrl, setAvatarUrl] = React.useState("");
const [systemPrompt, setSystemPrompt] = React.useState("");
const [runtime, setRuntime] = React.useState("");
const [model, setModel] = React.useState("");
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [provider, setProvider] = React.useState("");
const [aiConfigurationMode, setAiConfigurationMode] =
React.useState<AgentAiConfigurationMode>("defaults");
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const [namePoolText, setNamePoolText] = React.useState("");
const [envVars, setEnvVars] = React.useState<EnvVarsValue>({});
const [behaviorDraft, setBehaviorDraft] = React.useState(
emptyPersonaBehaviorDraft,
);
// The seed the draft is diffed against at submit: an untouched quad
// submits no behavior group, keeping unrelated edits hash-quiet.
const behaviorSeedRef = React.useRef(emptyPersonaBehaviorDraft);
// Tracks when the runtime was auto-seeded by the default-runtime effect in
// edit mode (i.e. the user never explicitly chose a runtime). Used to omit
// the seeded runtime from the submit payload for builtin definitions whose
// canonical runtime is null — the sync would revert it anyway.
const isRuntimeAutoSeededRef = React.useRef(false);
// Guards the seeding effect so it fires at most once per dialog-open.
// Without this, clearing runtime back to "" via "No preference" would re-
// trigger the effect (the `runtime` dep would pass the length guard) and
// snap the dropdown back to the default — an edit-mode regression.
const hasSeededForOpenRef = React.useRef(false);
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
const [hasUserChanges, setHasUserChanges] = React.useState(false);
const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false);
const {
globalConfig,
inheritedDefaults: {
provider: inheritedProviderDefault,
model: inheritedModelDefault,
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ open });
const defaultRuntime = React.useMemo(
() => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime),
[globalConfig.preferred_runtime, runtimes],
);
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
const shouldReduceMotion = useReducedMotion();
const initialModelProviderEditableWithoutRuntime = Boolean(
initialValues &&
"id" in initialValues &&
!hasText(initialValues.runtime) &&
(hasText(initialValues.model) || hasText(initialValues.provider)),
);
React.useEffect(() => {
onDirtyChange?.(hasUserChanges);
}, [hasUserChanges, onDirtyChange]);
React.useEffect(() => {
if (!open || !initialValues) {
return;
}
setDisplayName(initialValues.displayName);
setAvatarUrl(initialValues.avatarUrl ?? "");
setSystemPrompt(initialValues.systemPrompt);
setRuntime(initialValues.runtime ?? "");
setModel(initialValues.model ?? "");
setIsCustomModelEditing(false);
setProvider(initialValues.provider ?? "");
setAiConfigurationMode(
initialAgentAiConfigurationMode({
provider: initialValues.provider ?? "",
model: initialValues.model ?? "",
}),
);
setIsCustomProviderEditing(false);
const nextNamePoolText =
"namePool" in initialValues
? formatPersonaNamePoolText(initialValues.namePool)
: "";
const nextEnvVars =
"envVars" in initialValues ? (initialValues.envVars ?? {}) : {};
const nextBehaviorDraft = draftFromBehavior(initialValues.behavior);
behaviorSeedRef.current = draftFromBehavior(initialValues.behavior);
setBehaviorDraft(nextBehaviorDraft);
setNamePoolText(nextNamePoolText);
setEnvVars(nextEnvVars);
// Advanced always starts collapsed and only changes from its toggle.
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
setHasUserChanges(false);
isRuntimeAutoSeededRef.current = false;
hasSeededForOpenRef.current = false;
}, [initialValues, open]);
React.useEffect(() => {
if (
!open ||
!initialValues ||
initialValues.runtime?.trim() ||
runtimesLoading ||
runtime.trim().length > 0 ||
defaultRuntime === null ||
hasSeededForOpenRef.current
) {
return;
}
setRuntime(defaultRuntime.id);
hasSeededForOpenRef.current = true;
if ("id" in initialValues) {
// Edit mode: record that this runtime was auto-seeded so the submit path
// can omit it from the payload for builtin definitions (canonical runtime
// null; sync would revert the value anyway). Explicit user changes via
// the dropdown clear this flag.
isRuntimeAutoSeededRef.current = true;
}
}, [defaultRuntime, initialValues, open, runtime, runtimesLoading]);
// Keep an inherited Create runtime synced with defaults saved in-place.
React.useEffect(() => {
if (
!open ||
!initialValues ||
"id" in initialValues ||
initialValues.runtime?.trim() ||
aiConfigurationMode !== "defaults" ||
runtimesLoading ||
defaultRuntime === null ||
(runtime.trim().length > 0 && !isRuntimeAutoSeededRef.current)
) {
return;
}
if (runtime !== defaultRuntime.id) setRuntime(defaultRuntime.id);
isRuntimeAutoSeededRef.current = true;
hasSeededForOpenRef.current = true;
}, [
aiConfigurationMode,
defaultRuntime,
initialValues,
open,
runtime,
runtimesLoading,
]);
// Keep setup guidance reachable when no available runtime can be inherited.
React.useEffect(() => {
if (
open &&
isCreateMode &&
!runtimesLoading &&
defaultRuntime === null &&
runtime.trim().length === 0
) {
setAiConfigurationMode("custom");
}
}, [defaultRuntime, isCreateMode, open, runtime, runtimesLoading]);
function handleOpenChange(next: boolean) {
// The catalog may veto embedded close requests; preserve the draft until unmount.
if (!next && !embedded) {
setDisplayName("");
setAvatarUrl("");
setSystemPrompt("");
setRuntime("");
setModel("");
setIsCustomModelEditing(false);
setProvider("");
setAiConfigurationMode("defaults");
setIsCustomProviderEditing(false);
setNamePoolText("");
setEnvVars({});
setBehaviorDraft(emptyPersonaBehaviorDraft);
behaviorSeedRef.current = emptyPersonaBehaviorDraft;
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
setHasUserChanges(false);
setIsAddHarnessOpen(false);
// isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the
// [initialValues, open] effect resets both when the dialog re-opens.
}
onOpenChange(next);
}
async function handleSubmit() {
// D1: the same localModeSatisfied gate as canSubmit prevents form-submit
// (Enter) from bypassing a missing credential.
if (!initialValues || !localModeSatisfied || !canSubmit) return;
const {
runtime: runtimeForSubmit,
model: modelForSubmit,
provider: providerForSubmit,
} = buildRuntimeModelProviderPayload({
runtime,
model: aiConfigurationMode === "defaults" ? "" : model,
provider: aiConfigurationMode === "defaults" ? "" : provider,
isEditMode: "id" in initialValues,
isAutoSeeded: isRuntimeAutoSeededRef.current,
initialPreviousRuntime: initialValues.runtime?.trim() ?? "",
initialModel: initialValues.model,
initialProvider: initialValues.provider,
initialModelProviderEditableWithoutRuntime,
});
const namePool = parsePersonaNamePoolText(namePoolText);
const namePoolInput =
namePool.length > 0
? namePool
: "namePool" in initialValues
? []
: undefined;
const baseInput = {
displayName: displayName.trim(),
avatarUrl: avatarUrl.trim() || undefined,
systemPrompt: systemPrompt,
runtime: runtimeForSubmit,
model: modelForSubmit,
provider: providerForSubmit,
namePool: namePoolInput,
envVars,
behavior: behaviorForSubmit(
behaviorDraft,
behaviorSeedRef.current,
"id" in initialValues,
),
};
if ("id" in initialValues) {
await onSubmit(
{
id: initialValues.id,
...baseInput,
},
{
publishCatalogUpdates: publishCatalogUpdatesOnSave && hasUserChanges,
},
);
return;
}
await onSubmit(baseInput, { publishCatalogUpdates: false });
}
function handleSubmitForm(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
void handleSubmit();
}
const selectedRuntime = runtimes.find((p) => p.id === runtime);
const blankRuntimeModelProviderEditable =
initialModelProviderEditableWithoutRuntime && runtime.trim().length === 0;
const runtimeCanChooseLlmProvider =
runtimeSupportsLlmProviderSelection(runtime) ||
blankRuntimeModelProviderEditable;
const llmProviderFieldVisible =
(runtime.trim().length > 0 && runtimeCanChooseLlmProvider) ||
blankRuntimeModelProviderEditable;
const trimmedProvider = provider.trim();
// Required credential env keys and file-layer config; silences requirements satisfied in the file layer.
const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, {
enabled: open,
});
function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) {
setHasUserChanges(true);
setAiConfigurationMode(nextMode);
setIsCustomProviderEditing(false);
setIsCustomModelEditing(false);
const nextPair = agentAiConfigurationPairForMode({
current: { provider, model },
inherited: runtimeCanChooseLlmProvider
? {
provider: inheritedProviderDefault.value,
model: inheritedModelDefault.value,
}
: { provider: "", model: runtimeFileConfig?.model?.trim() ?? "" },
mode: nextMode,
needsProviderSelection: runtimeCanChooseLlmProvider,
});
setProvider(nextPair.provider);
setModel(nextPair.model);
}
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
const localModeGate = React.useMemo(
() =>
computeLocalModeGate({
bakedEnvKeys,
envVars,
globalEnvVars: globalConfig.env_vars,
globalProvider: inheritedProviderDefault.value,
globalModel: inheritedModelDefault.value,
isProviderMode: false,
model,
provider: trimmedProvider,
runtimeId: runtime,
runtimeFileConfig,
}),
[
bakedEnvKeys,
envVars,
globalConfig.env_vars,
inheritedModelDefault.value,
inheritedProviderDefault.value,
model,
trimmedProvider,
runtime,
runtimeFileConfig,
],
);
// requiredEnvKeys: the gate already handles baked-, global-, and file-
// satisfied keys so no further filtering is needed.
const { requiredEnvKeys } = localModeGate;
const localModeSatisfied = localModeGate.satisfied;
// Effective provider: agent value → global fallback → file fallback.
// Mirrors the chain inside computeLocalModeGate so model-option scoping and
// model requiredness are consistent with the readiness gate.
const fileProvider = runtimeFileConfig?.provider?.trim() ?? "";
const effectiveProvider =
trimmedProvider || inheritedProviderDefault.value || fileProvider;
const apiKeyFieldState = useProviderApiKeyFieldState({
bakedEnvKeys,
effectiveEnvVars: envVars,
envVars,
fileSatisfiedEnvKeys: localModeGate.fileSatisfiedEnvKeys,
globalEnvVars: globalConfig.env_vars,
provider: effectiveProvider,
requiredEnvKeys,
});
const {
advancedRequiredEnvKeys,
inheritedLabel: apiKeyInheritedLabel,
isInherited: apiKeyIsInherited,
isRequired: apiKeyIsRequired,
secretEnvVar: topLevelSecretEnvVar,
value: apiKeyValue,
} = apiKeyFieldState;
const providerIsRequired =
aiConfigurationMode === "custom" && runtimeCanChooseLlmProvider;
const modelFieldVisible =
runtime.trim().length > 0 || blankRuntimeModelProviderEditable;
const isExplicitModelRequired = aiConfigurationMode === "custom";
const customAiPairSatisfied = agentAiConfigurationModeSatisfied(
aiConfigurationMode,
{ provider, model },
runtimeCanChooseLlmProvider,
);
const selectedRuntimeIsAvailable =
runtime.trim().length === 0 ||
selectedRuntime?.availability === "available";
// Gate model/provider validity through missingNormalizedFields — single
// source of truth with the readiness gate so display and Save can't drift.
const canSubmit =
canSubmitPersonaDialog({ displayName, isPending }) &&
(!isCreateMode || runtime.trim().length > 0) &&
(!isCreateMode || selectedRuntimeIsAvailable) &&
(!isCreateMode || !createSubmitBlocked) &&
// Crash-loop guard, create AND edit: an empty allowlist would crash
// every instance minted from this definition at startup.
personaBehaviorDraftValid(behaviorDraft) &&
// D1: localModeSatisfied covers both missingNormalizedFields AND
// missingEnvKeys — credential env keys now block submit, not just display.
localModeSatisfied &&
customAiPairSatisfied &&
!isAvatarUploadPending;
// Merge global env as the base layer so credential keys satisfied via global
// config are available to model discovery — same rationale as in AgentInstanceEditDialog.
const envVarsForDiscovery = React.useMemo(
() => ({ ...globalConfig.env_vars, ...envVars }),
[globalConfig.env_vars, envVars],
);
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars: envVarsForDiscovery,
isCustomProviderEditing,
modelFieldVisible,
open,
// Gate provider by runtime: runtimes that don't support LLM provider
// selection (codex, claude) must not inherit the global provider — doing
// so causes them to discover models from the wrong provider.
provider: runtimeSupportsLlmProviderSelection(runtime)
? effectiveProvider
: "",
selectedRuntime,
});
const staticModelOptions = getPersonaModelOptions(runtime, effectiveProvider);
const runtimeModelOptions = getRuntimePersonaModelOptions(runtime);
const {
isCustom: isModelCustom,
isRelayMesh,
options: modelOptions,
selectValue: modelSelectValue,
showCustomInput: showCustomModelInput,
} = relayMeshModelPickerState({
discoveredOptions: discoveredModelOptions,
fallbackOptions: staticModelOptions,
knownOptions: discoveredModelOptions ?? runtimeModelOptions,
isCustomEditing: isCustomModelEditing,
model,
modelFieldVisible,
provider: effectiveProvider,
});
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
// migration rewrites any persisted Databricks v1 values → v2. Hide the v1
// option there so it is not offered for new selections. OSS builds have no
// baked provider, so v1 remains visible.
const hideProviderIds = React.useMemo(
() =>
(bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER")
? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
: new Set<string>(),
[bakedEnvKeys],
);
const providerOptions = getPersonaProviderOptions(
trimmedProvider,
runtime,
inheritedProviderDefault.source === "global"
? inheritedProviderDefault.value
: "",
hideProviderIds,
);
const providerSelectValue = isCustomProviderEditing
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
const showCustomProviderInput =
llmProviderFieldVisible && isCustomProviderEditing;
const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE;
const { blankRuntimeOptionLabel, runtimeDropdownOptions } =
buildPersonaRuntimeDropdownOptions({
defaultRuntimeId: defaultRuntime?.id,
isCreateMode,
runtime,
runtimes,
runtimesLoading,
});
runtimeDropdownOptions.push(ADD_CUSTOM_HARNESS_OPTION);
const runtimeSummaryLabel = selectedRuntime
? formatRuntimeOptionLabel(selectedRuntime)
: runtime.trim() || "Not configured";
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions
.filter((option) => option.id.trim().length > 0)
.map((option) => ({
label: option.label,
value: option.id,
})),
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
];
const modelDropdownOptions: PersonaDropdownOption[] =
buildModelDropdownOptions({
allowCustom: !isRelayMesh,
globalModel: undefined,
loading: modelDiscoveryLoading && discoveredModelOptions === null,
loadingValue: MODEL_DISCOVERY_LOADING_VALUE,
options: modelOptions,
})
.filter(
(option) => isRelayMesh || option.value !== AUTO_MODEL_DROPDOWN_VALUE,
)
.map((option) =>
isRelayMesh && option.value === AUTO_MODEL_DROPDOWN_VALUE
? { ...option, label: "Automatic" }
: option,
);
const previewLabel = displayName.trim() || "Agent name";
const previewAvatarUrl = avatarUrl.trim() || null;
const runtimeWarningText = selectedRuntime
? runtimeAvailabilityWarning(selectedRuntime)
: null;
const runtimeWarning = runtimeWarningText ? (
<p className="text-xs text-warning">
{runtimeWarningText} Visit Settings &gt; Agents to set it up.
</p>
) : null;
const advancedFieldsTransition = shouldReduceMotion
? { duration: 0 }
: ADVANCED_FIELDS_MOTION_TRANSITION;
React.useEffect(() => {
if (
!open ||
!modelFieldVisible ||
isCustomModelEditing ||
!shouldClearKnownModelForSelectionScope({
model,
provider: effectiveProvider,
runtime,
})
) {
return;
}
setModel("");
setIsCustomModelEditing(false);
}, [
isCustomModelEditing,
model,
modelFieldVisible,
open,
effectiveProvider,
runtime,
]);
const selection: RuntimeModelProviderSelection = {
provider,
model,
isCustomProviderEditing,
isCustomModelEditing,
envVars,
};
function applySelection(next: RuntimeModelProviderSelection) {
setProvider(next.provider);
setModel(next.model);
setIsCustomProviderEditing(next.isCustomProviderEditing);
setIsCustomModelEditing(next.isCustomModelEditing);
setEnvVars(next.envVars);
}
function handleRuntimeDropdownChange(nextValue: string) {
const action = runtimeDropdownAction(nextValue);
if (action.kind === "add-custom-harness") {
setIsAddHarnessOpen(true);
return;
}
setHasUserChanges(true);
const nextRuntime = action.runtimeId;
// The user made an explicit choice — no longer auto-seeded.
isRuntimeAutoSeededRef.current = false;
setRuntime(nextRuntime);
applySelection(
selectionOnRuntimeChange(selection, {
previousRuntime: runtime,
nextRuntime,
nextRuntimeCanChooseProvider:
nextRuntime.trim().length > 0 &&
runtimeSupportsLlmProviderSelection(nextRuntime),
lockedRuntimeReset: "full",
}),
);
}
// Routed through the normal change handler so a harness registered inline
// resets model/provider exactly as a hand-picked one would. Scoped to `open`
// so a pending id can't outlive the dialog that started the registration.
const selectSavedHarness = usePendingHarnessSelection(
runtimes,
handleRuntimeDropdownChange,
open,
);
function handleProviderDropdownChange(nextValue: string) {
setHasUserChanges(true);
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") {
handleRuntimeDropdownChange("buzz-agent");
}
const nextSelection = selectionOnProviderDropdownChange(selection, {
runtime: nextProvider === "relay-mesh" ? "buzz-agent" : runtime,
nextValue,
clearModelWhenApiKeyMissing: true,
});
applySelection({
...nextSelection,
model: nextProvider === "relay-mesh" ? "auto" : nextSelection.model,
});
}
function handleModelDropdownChange(nextValue: string) {
setHasUserChanges(true);
applySelection(
selectionOnModelDropdownChange(selection, {
nextValue,
clearKnownModelOnCustomEntry: true,
isModelCustom,
}),
);
}
const footer = (
<AgentDefinitionDialogFooter
canSubmit={canSubmit}
isAvatarUploadPending={isAvatarUploadPending}
isPending={isPending}
onCancel={() => handleOpenChange(false)}
publishesCatalogUpdates={publishCatalogUpdatesOnSave && hasUserChanges}
submitLabel={submitLabel}
/>
);
const form = (
<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={() => {
setHasUserChanges(true);
setAvatarUrl("");
}}
onUploadPendingChange={setIsAvatarUploadPending}
onSelectAvatar={(nextAvatarUrl) => {
setHasUserChanges(true);
setAvatarUrl(nextAvatarUrl);
}}
/>
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-display-name"
>
Agent name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Fizz"
value={displayName}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-system-prompt"
>
Agent instructions
</label>
<div className={PERSONA_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-40 resize-y px-3 py-3 leading-5",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-system-prompt"
onChange={(event) => setSystemPrompt(event.target.value)}
placeholder="Describe what this agent should do."
value={systemPrompt}
/>
</div>
</div>
{modelFieldVisible ? (
<AgentAiConfigurationModeField
mode={aiConfigurationMode}
needsProviderSelection={runtimeCanChooseLlmProvider}
onModeChange={handleAiConfigurationModeChange}
/>
) : null}
<div
className="space-y-5"
data-testid={`agent-${aiConfigurationMode}-configuration-section`}
>
{aiConfigurationMode === "custom" ? (
<AgentHarnessField
disabled={isPending || runtimesLoading}
onValueChange={handleRuntimeDropdownChange}
options={runtimeDropdownOptions}
placeholder={blankRuntimeOptionLabel}
value={runtimeDropdownValue}
warning={runtimeWarning}
/>
) : null}
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
<div className="space-y-1.5">
<RequiredFieldLabel
htmlFor="persona-llm-provider"
isRequired={providerIsRequired}
>
LLM provider
{!providerIsRequired ? (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
) : null}
</RequiredFieldLabel>
<PersonaDropdownField
disabled={isPending}
id="persona-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder="Choose a provider"
value={providerSelectValue}
/>
{showCustomProviderInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isPending}
id="persona-custom-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{llmProviderFieldVisible &&
aiConfigurationMode === "custom" &&
topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isPending}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API key"}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
[topLevelSecretEnvVar]: next,
}));
}}
value={apiKeyValue}
/>
) : null}
<AnimatePresence initial={false}>
{modelFieldVisible && aiConfigurationMode === "custom" ? (
<PersonaModelField
disabled={isPending}
isExplicitModelRequired={isExplicitModelRequired}
model={model}
modelDiscoveryStatus={modelDiscoveryStatus}
modelDropdownOptions={modelDropdownOptions}
modelSelectValue={modelSelectValue}
onCustomModelChange={setModel}
showSharedComputeAutoHint={
isRelayMesh && modelSelectValue === AUTO_MODEL_DROPDOWN_VALUE
}
onModelValueChange={handleModelDropdownChange}
showCustomModelInput={showCustomModelInput}
transition={advancedFieldsTransition}
/>
) : null}
</AnimatePresence>
{aiConfigurationMode === "defaults" ? (
<AgentCreateAiDefaultsSummary
canChooseProvider={runtimeCanChooseLlmProvider}
harness={runtimeSummaryLabel}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
isConfigured={localModeGate.satisfied}
model={runtimeFileConfig?.model}
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
/>
) : null}
</div>
<AgentDefaultsDialog
onOpenChange={setAiDefaultsOpen}
open={runtimeCanChooseLlmProvider && aiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
<AddCustomHarnessDialog
onOpenChange={setIsAddHarnessOpen}
onSaved={selectSavedHarness}
open={isAddHarnessOpen}
/>
<div className="space-y-3">
<button
aria-expanded={showAdvancedFields}
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setShowAdvancedFields((current) => !current)}
type="button"
>
<span>Advanced</span>
{(isCreateMode && createSubmitBlocked) ||
localModeGate.missingEnvKeys.some((key) =>
advancedRequiredEnvKeys.includes(key),
) ? (
<span
aria-hidden="true"
className="rounded-full bg-destructive/10 px-2 py-0.5 text-xs text-destructive"
data-testid="persona-advanced-required-badge"
>
Required
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
showAdvancedFields && "rotate-180",
)}
/>
</button>
<AnimatePresence initial={false}>
{showAdvancedFields ? (
<motion.div
animate={{ height: "auto", opacity: 1, scale: 1 }}
className="origin-top overflow-hidden"
exit={{ height: 0, opacity: 0, scale: 0.98 }}
initial={{ height: 0, opacity: 0, scale: 0.98 }}
key="persona-advanced-fields"
transition={advancedFieldsTransition}
>
<PersonaAdvancedFields
afterRespondTo={isCreateMode ? createRunSection : undefined}
behaviorDraft={behaviorDraft}
disabled={isPending}
envVars={envVars}
fileSatisfiedEnvKeys={localModeGate.fileSatisfiedEnvKeys}
hiddenEnvKeys={
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
}
inheritedEnvVars={inheritedEnvVarsForAdvanced}
model={model}
modelTuningRuntimeId={runtime}
namePoolText={namePoolText}
catalogStatus={runtimeCatalogStatus}
selectedRuntime={selectedRuntime}
onBehaviorDraftChange={(nextBehaviorDraft) => {
setHasUserChanges(true);
setBehaviorDraft(nextBehaviorDraft);
}}
onEnvVarsChange={setEnvVars}
onNamePoolTextChange={setNamePoolText}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
{error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : null}
</div>
</form>
);
return (
<AgentDefinitionDialogShell
description={description}
embedded={embedded}
footer={footer}
onOpenChange={(nextOpen) => {
if (!nextOpen && (isPending || isAvatarUploadPending)) return;
handleOpenChange(nextOpen);
}}
open={open}
title={title}
>
{form}
</AgentDefinitionDialogShell>
);
}