Files
buzz/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp f45e8e80e2 fix(desktop): clamp internal agent access for every backend
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
2026-07-31 14:27:28 -07:00

1229 lines
47 KiB
TypeScript

import * as React from "react";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import {
useAcpRuntimesQuery,
useAgentAccessOwnerOnlyQuery,
useAgentConfigSurface,
useBakedBuildEnvKeysQuery,
usePersonasQuery,
useStartManagedAgentMutation,
useUpdateManagedAgentMutation,
} from "@/features/agents/hooks";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import type {
ManagedAgent,
RespondToMode,
UpdateManagedAgentInput,
} from "@/shared/api/types";
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
formatRuntimeOptionLabel,
getDefaultLlmModelLabel,
getDefaultPersonaRuntime,
getPersonaProviderOptions,
isMissingRequiredDropdownField,
NO_RUNTIME_DROPDOWN_VALUE,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
runtimeSupportsLlmProviderSelection,
shouldClearKnownModelForSelectionScope,
sortPersonaRuntimes,
type PersonaDropdownOption,
} from "./agentConfigOptions";
import {
modelDropdownOptions as buildModelDropdownOptions,
relayMeshModelPickerState,
} from "./relayMeshModelPicker";
import {
computeEditAgentFormValidity,
envVarsEqual,
isEditAgentProviderSaveValid,
resolveAgentCommandUpdate,
resolveInheritedRuntimeSubmission,
resolveRuntimeProviderCapability,
} from "./personaRuntimeModel";
import {
selectionOnModelDropdownChange,
selectionOnProviderDropdownChange,
selectionOnRuntimeChange,
type RuntimeModelProviderSelection,
} from "./runtimeModelProviderSelection";
import { AgentCreationPreview } from "./AgentCreationPreview";
import { InternalAgentAccessField } from "./InternalAgentAccessField";
import type { EnvVarsValue } from "./EnvVarsEditor";
import { useRequiredCredentialState } from "./useRequiredCredentialState";
import { PersonaDropdownField } from "./PersonaDropdownField";
import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
import {
getBakedModelInheritLabel,
getBakedProviderInheritLabel,
} from "./bakedEnvHelpers";
import { getProviderApiKeyEnvVar } from "./agentConfigOptions";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { resolveModelFieldStatusMessage } from "./agentConfigControls";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning";
import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
import {
ADD_CUSTOM_HARNESS_OPTION,
runtimeDropdownAction,
usePendingHarnessSelection,
} from "./addCustomHarness";
const ADVANCED_FIELDS_MOTION_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;
export function AgentInstanceEditDialog({
agent,
initialFocus,
open,
onEditLinkedPersona,
onOpenChange,
onUpdated,
}: {
agent: ManagedAgent;
/** Optional field to scroll/focus when the dialog opens from a card deep-link. */
initialFocus?: EditAgentFocusTarget;
open: boolean;
/** Present only when the linked definition is editable (non-built-in, resolved). Caller closes this dialog and enters definition-edit. */
onEditLinkedPersona?: () => void;
onOpenChange: (open: boolean) => void;
onUpdated?: (agent: ManagedAgent) => void;
}) {
const updateMutation = useUpdateManagedAgentMutation();
const startMutation = useStartManagedAgentMutation();
const runtimesQuery = useAcpRuntimesQuery({ enabled: open });
const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null);
const runtimes = runtimesQuery.data ?? [];
const [name, setName] = React.useState(agent.name);
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand);
const [agentCommand, setAgentCommand] = React.useState(agent.agentCommand);
const [originalAgentCommand, setOriginalAgentCommand] = React.useState(
agent.agentCommand,
);
const [inheritHarness, setInheritHarness] = React.useState(
agent.personaId != null && agent.agentCommandOverride == null,
);
const [agentArgs, setAgentArgs] = React.useState(agent.agentArgs.join(","));
const [parallelism, setParallelism] = React.useState(
String(agent.parallelism),
);
const [systemPrompt, setSystemPrompt] = React.useState(
agent.systemPrompt ?? "",
);
const [model, setModel] = React.useState(agent.model ?? "");
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [provider, setProvider] = React.useState(agent.provider ?? "");
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] =
React.useState(agent.autoRestartOnConfigChange);
const personasQuery = usePersonasQuery();
const linkedPersona = React.useMemo(
() =>
agent.personaId
? (personasQuery.data?.find((p) => p.id === agent.personaId) ?? null)
: null,
[agent.personaId, personasQuery.data],
);
const inheritedEnvVars = linkedPersona?.envVars ?? {};
const [respondTo, setRespondTo] = React.useState<RespondToMode>(
agent.respondTo,
);
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
agent.respondToAllowlist,
);
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? "");
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
// Runtime selector: defaults to "custom" until the dialog opens and the
// catalog loads. The open-effect re-derives the correct id from the catalog.
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom");
// Tracks whether the user has made an in-dialog runtime selection.
const runtimeTouched = React.useRef(false);
// Reset form state only when the dialog opens or when switching to a different agent.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — including agent fields would re-fire on every 5s poll and wipe edits
React.useEffect(() => {
if (open) {
setName(agent.name);
setAcpCommand(agent.acpCommand);
setAgentCommand(agent.agentCommand);
setOriginalAgentCommand(agent.agentCommand);
setInheritHarness(
agent.personaId != null && agent.agentCommandOverride == null,
);
setAgentArgs(agent.agentArgs.join(","));
setParallelism(String(agent.parallelism));
setSystemPrompt(agent.systemPrompt ?? "");
setModel(agent.model ?? "");
setIsCustomModelEditing(false);
setProvider(agent.provider ?? "");
setIsCustomProviderEditing(false);
setEnvVars(agent.envVars);
setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange);
setRespondTo(agent.respondTo);
setRespondToAllowlist(agent.respondToAllowlist);
setAvatarUrl(agent.avatarUrl ?? "");
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
setIsAddHarnessOpen(false);
runtimeTouched.current = false;
const matched =
runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
runtimes.find((r) => r.id === agent.agentCommand.trim());
setSelectedRuntimeId(matched ? matched.id : "custom");
updateMutation.reset();
}
}, [open, agent.pubkey]);
// Re-derive the runtime id when the catalog loads.
React.useEffect(() => {
if (!open || runtimeTouched.current || runtimes.length === 0) {
return;
}
const matched =
runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
runtimes.find((r) => r.id === agent.agentCommand.trim());
if (matched) {
setSelectedRuntimeId(matched.id);
}
}, [open, runtimes, agent.agentCommand]);
// Build the sorted runtime catalog for the dropdown.
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimes),
[runtimes],
);
const selectedRuntime = React.useMemo(
() => runtimes.find((r) => r.id === selectedRuntimeId),
[runtimes, selectedRuntimeId],
);
const runtimeDropdownValue = selectedRuntimeId || NO_RUNTIME_DROPDOWN_VALUE;
const runtimeDropdownOptions: PersonaDropdownOption[] = React.useMemo(() => {
const options: PersonaDropdownOption[] = [
...sortedRuntimes.map((candidate) => ({
label: formatRuntimeOptionLabel(candidate),
value: candidate.id,
})),
{ label: "Custom command", value: "custom" },
];
if (
selectedRuntimeId &&
selectedRuntimeId !== "custom" &&
!options.some((o) => o.value === selectedRuntimeId)
) {
options.push({
label: `${selectedRuntimeId} (current)`,
value: selectedRuntimeId,
});
}
options.push(ADD_CUSTOM_HARNESS_OPTION);
return options;
}, [sortedRuntimes, selectedRuntimeId]);
// Resolve the dialog-opening command as the catalog loads. Edit-state runtime
// ids mutate during selection changes and cannot identify the original state.
const originalRuntimeSupportsProvider = React.useMemo(() => {
const originalCommand = originalAgentCommand.trim();
const matched =
runtimes.find((r) => r.command?.trim() === originalCommand) ??
runtimes.find((r) => r.id === originalCommand);
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
}, [runtimes, originalAgentCommand]);
// The runtime id that will actually be active after submit. When inheriting,
// resolve from the LINKED PERSONA's runtime — that is what will run once the
// override is cleared. Deriving from agent.agentCommand here is wrong for a
// pinned agent that just toggled "Inherit runtime from template": the override
// (e.g. a Claude pin) is still present on the record, so it would resolve to
// the old pin instead of the persona's runtime, hiding required credentials.
// Fall back to the agent.agentCommand dual-match (command path, then id) only
// when there is no linked persona or its runtime is unset. This single
// prospective id feeds BOTH the block-save gate (requiredEnvKeys) and the
// submit path so they never disagree on which runtime is being saved.
const prospectiveRuntimeId = React.useMemo(() => {
if (!inheritHarness) {
return selectedRuntime?.id ?? selectedRuntimeId;
}
const personaRuntimeId = linkedPersona?.runtime?.trim();
if (personaRuntimeId) {
return (
runtimes.find((r) => r.id === personaRuntimeId)?.id ?? personaRuntimeId
);
}
return (
runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim())
?.id ??
runtimes.find((r) => r.id === agent.agentCommand.trim())?.id ??
// Fall back to the app default runtime so discovery can run for agents
// whose persona has no runtime set (e.g. freshly-added catalog builtins).
getDefaultPersonaRuntime(runtimes)?.id ??
""
);
}, [
inheritHarness,
linkedPersona?.runtime,
runtimes,
agent.agentCommand,
selectedRuntime?.id,
selectedRuntimeId,
]);
const llmProviderFieldVisible =
runtimeSupportsLlmProviderSelection(prospectiveRuntimeId);
// One-shot focus: when the dialog opens from a card deep-link, scroll and
// focus the relevant field. The effect re-runs when `llmProviderFieldVisible`
// changes so a provider-field focus request fires once the field materializes.
const normalizedFieldFocusFiredRef = React.useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — reset guard on these three; llmProviderFieldVisible drives the focus attempt below
React.useEffect(() => {
normalizedFieldFocusFiredRef.current = false;
}, [open, initialFocus, agent.pubkey]);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — llmProviderFieldVisible is the availability signal that re-triggers the focus attempt; agent.pubkey handles agent-switch
React.useEffect(() => {
if (!open || !initialFocus) return;
if (initialFocus.type !== "normalized_field") return;
if (normalizedFieldFocusFiredRef.current) return;
const targetId =
initialFocus.field === "provider"
? "edit-agent-llm-provider"
: "edit-agent-model";
const el = document.getElementById(targetId);
if (!(el instanceof HTMLElement)) return;
normalizedFieldFocusFiredRef.current = true;
const id = requestAnimationFrame(() => {
el.scrollIntoView({ block: "nearest" });
el.focus();
});
return () => cancelAnimationFrame(id);
}, [open, initialFocus, agent.pubkey, llmProviderFieldVisible]);
// Provider + env to PERSIST on submit — also fed to the credential gate so
// gate, saved record, and spawn snapshot all agree on one resolved value.
// See resolveInheritedRuntimeSubmission for the inherit/transition contract.
const inheritedSubmission = React.useMemo(
() =>
resolveInheritedRuntimeSubmission({
inheritHarness,
// Inherit-transition vs. Default-clear — see resolveInheritedRuntimeSubmission.
agentWasHarnessPinned: agent.agentCommandOverride != null,
provider,
personaProvider: linkedPersona?.provider ?? "",
model,
personaModel: linkedPersona?.model ?? null,
envVars,
personaEnvVars: inheritedEnvVars,
}),
[
inheritHarness,
agent.agentCommandOverride,
provider,
linkedPersona?.provider,
model,
linkedPersona?.model,
envVars,
inheritedEnvVars,
],
);
const {
globalConfig,
inheritedDefaults: {
provider: inheritedProviderDefault,
model: inheritedModelDefault,
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ inheritedEnvVars, open });
// Runtime/provider-required credential state, derived from the PROSPECTIVE
// post-submit runtime — see the hook for the inherit-transition rationale.
// Pass globalProvider so the hook uses it as a fallback when the per-agent
// provider is empty (global-provider-only configs must surface required keys).
// Pass globalEnvVars so keys satisfied by global config are excluded from
// requiredEnvKeys and do not block Save (display and gate agree).
const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } =
useRequiredCredentialState({
open,
prospectiveRuntimeId,
provider: inheritedSubmission.provider ?? "",
globalProvider: inheritedProviderDefault.value,
envVars: inheritedSubmission.envVars,
globalEnvVars: globalConfig.env_vars,
personaEnvVars: inheritHarness ? inheritedEnvVars : undefined,
});
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
enabled: open,
});
// Merge global env as the base layer so credential keys satisfied via global
// config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use
// `inheritedSubmission.envVars` (the same snapshot the credential gate
// validates) rather than raw `envVars`, so an inherit-transition that layers
// in persona env vars is reflected in discovery. Agent-local env takes
// precedence, matching the agent → global → file spawn-path precedence.
const envVarsForDiscovery = React.useMemo(
() => ({ ...globalConfig.env_vars, ...inheritedSubmission.envVars }),
[globalConfig.env_vars, inheritedSubmission.envVars],
);
const effectiveProvider =
(inheritedSubmission.provider ?? "").trim() ||
inheritedProviderDefault.value;
const providerForDiscovery = llmProviderFieldVisible ? effectiveProvider : "";
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars: envVarsForDiscovery,
isCustomProviderEditing,
modelFieldVisible: true,
open,
provider: providerForDiscovery,
selectedRuntime,
});
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display.
// The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating.
// D2/D3: the top-level API key owns display, while the readiness gate keeps
// the complete required-key list. The effective snapshot covers persona
// inheritance during an instance inherit transition.
const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider);
const personaSatisfied =
providerApiKeyEnvVar != null &&
!(providerApiKeyEnvVar in envVars) &&
(inheritedEnvVars[providerApiKeyEnvVar] ?? "").length > 0;
const apiKeyFieldState = useProviderApiKeyFieldState({
bakedEnvKeys,
effectiveEnvVars: inheritedSubmission.envVars,
envVars,
fileSatisfiedEnvKeys,
globalEnvVars: globalConfig.env_vars,
personaSatisfied,
provider: effectiveProvider,
requiredEnvKeys,
});
const {
advancedRequiredEnvKeys,
inheritedLabel: apiKeyInheritedLabel,
isInherited: apiKeyIsInherited,
isRequired: apiKeyIsRequired,
secretEnvVar: topLevelSecretEnvVar,
value: apiKeyValue,
} = apiKeyFieldState;
// Clear model when provider scope changes and current model is no longer valid.
React.useEffect(() => {
if (
!open ||
isCustomModelEditing ||
!shouldClearKnownModelForSelectionScope({
model,
provider: providerForDiscovery,
runtime: selectedRuntime?.id ?? selectedRuntimeId,
})
) {
return;
}
setModel("");
setIsCustomModelEditing(false);
}, [
isCustomModelEditing,
model,
open,
providerForDiscovery,
selectedRuntime,
selectedRuntimeId,
]);
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;
}
const nextRuntimeId = action.runtimeId;
const previousRuntimeId = selectedRuntimeId;
const nextRuntime = runtimes.find((r) => r.id === nextRuntimeId);
// Mark that the user has made an explicit runtime choice. The catalog-arrival
// effect will no longer overwrite selectedRuntimeId after this point.
runtimeTouched.current = true;
const resolvedRuntimeId = nextRuntimeId || "custom";
setSelectedRuntimeId(resolvedRuntimeId);
const isCustomCommand = resolvedRuntimeId === "custom";
// Only pin the harness when the selection can actually supply a command:
// - "Custom command": the Advanced command input becomes editable, so the
// user provides the command.
// - a catalog entry with a concrete command: we set it below.
// A catalog entry with command:null (availability adapter_missing /
// not_installed) can't produce a runnable command — clearing inheritance
// there would omit agentCommand on Save (command unchanged) while the
// provider/model logic treats the new runtime as effective, so an inherited
// Claude agent could persist a Databricks provider while still running
// Claude. Keep inheriting in that case.
if (isCustomCommand || nextRuntime?.command) {
setInheritHarness(false);
}
// When switching to a catalog-known runtime, update the agent command to
// its resolved command so the command field stays consistent.
if (nextRuntime?.command) {
setAgentCommand(nextRuntime.command);
const newArgs = nextRuntime.defaultArgs.join(",");
setAgentArgs(newArgs);
}
applySelection(
selectionOnRuntimeChange(selection, {
previousRuntime: previousRuntimeId,
nextRuntime: nextRuntime?.id ?? nextRuntimeId,
nextRuntimeCanChooseProvider: runtimeSupportsLlmProviderSelection(
nextRuntime?.id ?? nextRuntimeId,
),
lockedRuntimeReset: "full",
}),
);
}
// Routed through the normal change handler so a harness registered inline
// pins its command and resets model/provider like a hand-picked one. 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) {
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
if (nextProvider === "relay-mesh" && selectedRuntimeId !== "buzz-agent") {
handleRuntimeDropdownChange("buzz-agent");
}
const nextSelection = selectionOnProviderDropdownChange(selection, {
runtime:
nextProvider === "relay-mesh"
? "buzz-agent"
: (selectedRuntime?.id ?? selectedRuntimeId),
nextValue,
clearModelWhenApiKeyMissing: false,
});
applySelection({
...nextSelection,
model: nextProvider === "relay-mesh" ? "auto" : nextSelection.model,
});
}
function handleModelDropdownChange(nextValue: string) {
applySelection(
selectionOnModelDropdownChange(selection, {
nextValue,
clearKnownModelOnCustomEntry: false,
isModelCustom: false,
}),
);
}
function handleOpenChange(next: boolean) {
onOpenChange(next);
}
const providerValid = isEditAgentProviderSaveValid({
llmProviderFieldVisible,
currentProvider: provider,
originalProvider: agent.provider,
globalProvider: inheritedProviderDefault.value,
originalRuntimeSupportsProvider,
});
const canSubmit =
computeEditAgentFormValidity({
name,
parallelism,
agentAcpCommand: agent.acpCommand,
acpCommand,
respondTo,
respondToAllowlistLength: respondToAllowlist.length,
selectedRuntimeId,
inheritHarness,
agentCommand,
requiredEnvKeyMissing,
}) &&
providerValid &&
!updateMutation.isPending &&
!isAvatarUploadPending;
async function handleSubmit() {
try {
const parsedParallelism = Number.parseInt(parallelism, 10);
const parsedArgs = agentArgs
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
// Model to persist — from the shared inherited-submission snapshot so a
// provider-backed inherit-transition carries the persona model (readiness
// requires one) and a deliberate local model still wins.
const normalizedModel = inheritedSubmission.model;
// Harness pin resolution — see resolveAgentCommandUpdate for the full
// sentinel/pin/no-op contract, including the inherit→pin transition where
// the prefilled command equals the original but must still be pinned.
const agentCommandUpdate = resolveAgentCommandUpdate({
inheritHarness,
agentCommand,
originalAgentCommand: agent.agentCommand,
agentCommandOverride: agent.agentCommandOverride ?? null,
});
// Classify the effective post-submit runtime's provider capability as a
// tri-state: "capable" persists the provider, "locked" clears it (only
// when we KNOW it's provider-locked, e.g. Claude), "unknown" OMITS it so a
// transient/custom state never becomes a destructive write. Resolved
// STATICALLY (by id) so a not-yet-loaded catalog can't misclassify a known
// runtime as "unknown" — see resolveRuntimeProviderCapability. The runtime
// id is the shared prospectiveRuntimeId, so submit and the block-save gate
// always agree on which runtime is being saved.
const providerRuntimeCapability = resolveRuntimeProviderCapability(
prospectiveRuntimeId,
runtimeSupportsLlmProviderSelection(prospectiveRuntimeId),
);
// Provider + env to persist — the shared inherited-submission snapshot
// (same values the credential gate validates), so gate ↔ record ↔ spawn
// all agree. See resolveInheritedRuntimeSubmission.
const normalizedSubmitProvider = inheritedSubmission.provider;
const submitEnvVars = inheritedSubmission.envVars;
const input: UpdateManagedAgentInput = {
pubkey: agent.pubkey,
name: name.trim() !== agent.name ? name.trim() : undefined,
// relayUrl deliberately never submitted: the legacy per-record pin is
// ignored (#2122) and the stored value is preserved as-is.
acpCommand:
acpCommand.trim() !== agent.acpCommand
? acpCommand.trim()
: undefined,
agentCommand: agentCommandUpdate,
// A non-inheriting selection is a deliberate pin — signal it so the
// backend preserves a Custom/runtime command even when it maps to the
// linked persona's own runtime (otherwise it would be dropped back to
// inherit). Omitted (falsy) when inheriting or on a name-only edit.
harnessOverride:
agentCommandUpdate != null ? !inheritHarness : undefined,
agentArgs:
parsedArgs.join(",") !== agent.agentArgs.join(",")
? parsedArgs
: undefined,
parallelism:
parsedParallelism > 0 && parsedParallelism !== agent.parallelism
? parsedParallelism
: undefined,
// Linked instances defer model/provider/systemPrompt to the definition.
systemPrompt:
linkedPersona != null
? undefined
: (systemPrompt.trim() || null) !== agent.systemPrompt
? systemPrompt.trim() || null
: undefined,
model:
linkedPersona != null
? undefined
: normalizedModel !== (agent.model ?? null)
? normalizedModel
: undefined,
// Tri-state provider persistence keyed on providerRuntimeCapability:
// "capable" → persist: value if changed, omit if unchanged.
// "locked" → clear: send null if provider was set, else omit.
// "unknown" → omit always (never send null for a transient state).
// llmProviderFieldVisible is for UX visibility only; not used here.
provider:
linkedPersona != null
? undefined
: providerRuntimeCapability === "capable"
? normalizedSubmitProvider !== (agent.provider ?? null)
? normalizedSubmitProvider
: undefined
: providerRuntimeCapability === "locked"
? (agent.provider ?? null) !== null
? null
: undefined
: undefined, // "unknown" → omit always
envVars: envVarsEqual(submitEnvVars, agent.envVars)
? undefined
: submitEnvVars,
respondTo: respondTo !== agent.respondTo ? respondTo : undefined,
// The allowlist is preserved across mode toggles in local UI state
// (so a user can flip away from allowlist and back without losing
// their entries), but we only send it on the wire when (a) it
// actually changed, AND (b) the saved mode will need it. Sending
// an allowlist while switching to a non-allowlist mode would be
// harmless server-side, but it's noise in the persisted record.
respondToAllowlist:
respondTo === "allowlist" &&
respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",")
? respondToAllowlist
: undefined,
};
const result = await updateMutation.mutateAsync(input);
if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) {
// Standalone setter (mirrors start-on-app-launch) — not part of
// UpdateManagedAgentInput, so the frozen update shape stays frozen.
await setManagedAgentAutoRestart(
agent.pubkey,
autoRestartOnConfigChange,
);
}
showAgentProfileSyncWarning(result.agent.name, result.profileSyncError);
handleOpenChange(false);
onUpdated?.(result.agent);
// The auto-restart policy deliberately never fires for a stopped or
// failing agent (a broken agent must not auto-loop), so an edit meant
// to FIX one silently waits for a manual start. Offer that start
// explicitly instead of relying on the user to know the policy.
if (!isManagedAgentActive(result.agent)) {
const startedName = result.agent.name;
toast(`${startedName} saved while stopped.`, {
action: {
label: "Start now",
onClick: () => {
startMutation.mutate(result.agent.pubkey, {
onSuccess: () => toast.success(`${startedName} started.`),
onError: (error) =>
toast.error(
error instanceof Error
? `${startedName} failed to start: ${error.message}`
: `${startedName} failed to start.`,
),
});
},
},
});
}
} catch {
// React Query stores the error; keep dialog open and render it inline.
}
}
// Model and provider field derived state
const normalizedConfig = configSurfaceQuery.data?.normalized;
const modelRequired = isMissingRequiredDropdownField(
normalizedConfig?.model,
model,
);
const providerRequired = isMissingRequiredDropdownField(
normalizedConfig?.provider,
provider,
);
const inheritedModelLabel =
inheritedModelDefault.source === "build"
? getBakedModelInheritLabel(inheritedModelDefault.value)
: getDefaultLlmModelLabel(inheritedModelDefault.value);
const {
isRelayMesh,
options: effectiveModelOptions,
selectValue: modelSelectValue,
showCustomInput: showCustomModelInput,
} = relayMeshModelPickerState({
discoveredOptions: discoveredModelOptions,
fallbackOptions: [{ id: "", label: inheritedModelLabel }],
isCustomEditing: isCustomModelEditing,
model,
provider: providerForDiscovery,
});
const modelDropdownOptions = buildModelDropdownOptions({
allowCustom: !isRelayMesh,
globalModel: isRelayMesh ? undefined : inheritedModelDefault.value,
globalModelLabel: isRelayMesh ? undefined : inheritedModelLabel,
loading: modelDiscoveryLoading && discoveredModelOptions === null,
loadingValue: MODEL_DISCOVERY_LOADING_VALUE,
options: effectiveModelOptions,
});
const modelStatusMessage = resolveModelFieldStatusMessage({
discoveredModelOptions,
loading: modelDiscoveryLoading,
status: modelDiscoveryStatus,
});
// Provider field derived state
const trimmedProvider = provider.trim();
const hideProviderIds = React.useMemo(
() =>
(bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER")
? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
: new Set<string>(),
[bakedEnvKeys],
);
const providerOptions = getPersonaProviderOptions(
trimmedProvider,
selectedRuntime?.id ?? "",
inheritedProviderDefault.source === "global"
? inheritedProviderDefault.value
: "",
hideProviderIds,
);
const providerSelectValue = isCustomProviderEditing
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions.map((option) => ({
label:
option.id === "" && inheritedProviderDefault.source === "build"
? getBakedProviderInheritLabel(
inheritedProviderDefault.value,
providerOptions,
)
: option.label,
value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE,
})),
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
];
const previewLabel = name.trim() || "Agent name";
const previewAvatarUrl = avatarUrl.trim() || null;
const advancedFieldsTransition = shouldReduceMotion
? { duration: 0 }
: ADVANCED_FIELDS_MOTION_TRANSITION;
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<ChooserDialogContent
className="max-w-3xl border-0"
contentClassName="pt-3"
data-testid="edit-agent-dialog"
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={`Edit ${agent.name}`}
footer={
<div className="flex w-full items-center justify-end gap-2">
<Button
disabled={updateMutation.isPending || isAvatarUploadPending}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="edit-agent-dialog-submit"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
type="button"
>
{updateMutation.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
}
>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
{/* Avatar is definition-level identity. hideEditControl suppresses
the internal pencil badge; the CTA below is the only edit path. */}
<div className="flex flex-col items-center gap-2">
<AgentCreationPreview
avatarUrl={previewAvatarUrl}
hideEditControl
label={previewLabel}
onClearAvatar={() => setAvatarUrl("")}
onUploadPendingChange={setIsAvatarUploadPending}
onSelectAvatar={setAvatarUrl}
/>
{onEditLinkedPersona ? (
<Button
className="w-full"
onClick={() => {
handleOpenChange(false);
onEditLinkedPersona();
}}
size="sm"
type="button"
variant="outline"
>
Edit avatar
</Button>
) : (
<p className="text-center text-xs text-muted-foreground">
Avatar is shared identity
</p>
)}
</div>
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-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={updateMutation.isPending}
id="edit-agent-name"
onChange={(event) => setName(event.target.value)}
placeholder="Agent name"
value={name}
/>
</div>
</div>
<InternalAgentAccessField
accessLocked={agentAccessOwnerOnly === true}
allowlist={respondToAllowlist}
disabled={updateMutation.isPending}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
{/* Provider (runtime) */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-runtime"
>
Provider
</label>
<PersonaDropdownField
disabled={updateMutation.isPending}
id="edit-agent-runtime"
onValueChange={handleRuntimeDropdownChange}
options={runtimeDropdownOptions}
placeholder="Choose a provider"
value={runtimeDropdownValue}
/>
{selectedRuntime ? (
<p className="text-xs text-muted-foreground">
Detected at{" "}
<span className="font-medium">
{selectedRuntime.binaryPath ??
selectedRuntime.command ??
selectedRuntime.id}
</span>
</p>
) : null}
<AddCustomHarnessDialog
onOpenChange={setIsAddHarnessOpen}
onSaved={selectSavedHarness}
open={isAddHarnessOpen}
/>
</div>
{selectedRuntimeId === "custom" && !inheritHarness ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-command"
>
Agent command
</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={updateMutation.isPending}
id="edit-agent-command"
onChange={(event) => setAgentCommand(event.target.value)}
placeholder="Full path or shell command"
value={agentCommand}
/>
</div>
</div>
) : null}
{/* LLM provider */}
{llmProviderFieldVisible ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-llm-provider"
>
LLM provider
{providerRequired ? (
<span className="ml-1 text-destructive" aria-hidden="true">
*
</span>
) : (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>
Optional
</span>
)}
</label>
<PersonaDropdownField
disabled={updateMutation.isPending}
id="edit-agent-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder="Default (auto)"
value={providerSelectValue}
/>
{isCustomProviderEditing ? (
<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={updateMutation.isPending}
id="edit-agent-custom-provider"
onChange={(event) => setProvider(event.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={updateMutation.isPending}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={
effectiveProvider === "anthropic"
? "Anthropic API Key"
: "OpenAI API Key"
}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
[topLevelSecretEnvVar]: next,
}));
}}
value={apiKeyValue}
/>
) : null}
{/* Model */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-model"
>
Model
{modelRequired ? (
<span className="ml-1 text-destructive" aria-hidden="true">
*
</span>
) : (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
)}
</label>
<PersonaDropdownField
disabled={updateMutation.isPending || modelDiscoveryLoading}
id="edit-agent-model"
onValueChange={handleModelDropdownChange}
options={modelDropdownOptions}
placeholder="Default model"
value={modelSelectValue}
/>
{showCustomModelInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom model ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={updateMutation.isPending}
id="edit-agent-custom-model"
onChange={(event) => setModel(event.target.value)}
placeholder="Custom model ID"
value={model}
/>
</div>
) : null}
{modelStatusMessage ? (
<p className="text-xs text-muted-foreground">
{modelStatusMessage}
</p>
) : null}
</div>
<AgentAiDefaultsNotice
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
explicitModel={inheritedSubmission.model ?? ""}
explicitProvider={inheritedSubmission.provider ?? ""}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
/>
<AgentDefaultsDialog
onOpenChange={setAiDefaultsOpen}
open={aiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
{/* Advanced settings */}
<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>
<AdvancedRequiredBadge
envVars={inheritedSubmission.envVars}
requiredEnvKeys={advancedRequiredEnvKeys}
testId="edit-agent-advanced-required-badge"
/>
<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="edit-agent-advanced-fields"
transition={advancedFieldsTransition}
>
<EditAgentAdvancedFields
acpCommand={acpCommand}
agentArgs={agentArgs}
autoRestartOnConfigChange={autoRestartOnConfigChange}
disabled={updateMutation.isPending}
envVars={envVars}
fileSatisfiedEnvKeys={fileSatisfiedEnvKeys}
hiddenEnvKeys={
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
}
focusKey={
initialFocus?.type === "env_key"
? initialFocus.key
: undefined
}
inheritedEnvVars={inheritedEnvVarsForAdvanced}
inheritHarness={inheritHarness}
linkedPersona={linkedPersona}
model={inheritedSubmission.model ?? ""}
modelTuningRuntimeId={prospectiveRuntimeId}
parallelism={parallelism}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
systemPrompt={systemPrompt}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAutoRestartChange={setAutoRestartOnConfigChange}
onEnvVarsChange={setEnvVars}
onInheritHarnessChange={setInheritHarness}
onParallelismChange={setParallelism}
onSystemPromptChange={setSystemPrompt}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
{/* Error */}
{updateMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{updateMutation.error.message}
</p>
) : null}
</div>
</div>
</ChooserDialogContent>
</Dialog>
);
}