/** * Settings card for global agent configuration defaults. * * Lets the user set env vars, provider, and model that apply to ALL local * agents as the lowest-precedence user layer. Per-agent and persona configs * always win on collision. * * Precedence: baked floor < GLOBAL (this card) < persona < per-agent. */ import { AlertCircle, Check, Loader } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { getGlobalAgentConfig, setGlobalAgentConfig, } from "@/shared/api/tauriGlobalAgentConfig"; import type { GlobalAgentConfig } from "@/shared/api/types"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig"; import { useAcpRuntimesQuery, useRuntimeFileConfigQuery, } from "@/features/agents/hooks"; import { formatRuntimeOptionLabel, getDefaultPersonaRuntime, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, resetConfigForHarnessChange, sortPersonaRuntimes, } from "@/features/agents/ui/agentConfigOptions"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; import { AgentConfigFields, EMPTY_GLOBAL_CONFIG, } from "@/features/agents/ui/AgentConfigFields"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; type SaveState = "idle" | "saving" | "saved" | "error"; const PROGRESSIVE_FIELDS_TRANSITION = { duration: 0.22, ease: [0.23, 1, 0.32, 1], } as const; const PERSONA_SELECT_TRIGGER_CLASS = cn( PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, "h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60", ); export type GlobalAgentConfigSaveResult = Awaited< ReturnType >; type AgentDefaultsEditorProps = { layout?: "flat" | "grouped"; onDirtyChange?: (dirty: boolean) => void; onSaveSuccess?: (result: GlobalAgentConfigSaveResult) => void; onSavingChange?: (saving: boolean) => void; secondaryAction?: React.ReactNode; }; export function AgentDefaultsEditor({ layout = "grouped", onDirtyChange, onSaveSuccess, onSavingChange, secondaryAction, }: AgentDefaultsEditorProps) { const flatLayout = layout === "flat"; const shouldReduceMotion = useReducedMotion(); const [config, setConfig] = React.useState(EMPTY_GLOBAL_CONFIG); const configRef = React.useRef(config); const [dirty, setDirty] = React.useState(false); const [saveState, setSaveState] = React.useState("idle"); const [saveError, setSaveError] = React.useState(null); const [restartedCount, setRestartedCount] = React.useState(0); const [failedRestartCount, setFailedRestartCount] = React.useState(0); const [isLoading, setIsLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(false); const [configIsValid, setConfigIsValid] = React.useState(true); const [isCustomProvider, setIsCustomProvider] = React.useState(false); const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); const savedTimerRef = React.useRef | null>( null, ); const queryClient = useQueryClient(); const [bakedEnv, setBakedEnv] = React.useState([]); React.useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); // Load on mount — seed the shared TanStack Query cache so any dialog that // opens after this point reads the populated value on first render (no async // race). The query is also backed by its own queryFn for first-consumer // scenarios, but this eager seed eliminates the "settings card loaded, user // opens Create Agent before the lazy query fires" window. React.useEffect(() => { let cancelled = false; getGlobalAgentConfig() .then((loaded) => { if (!cancelled) { configRef.current = loaded; setConfig(loaded); setIsLoading(false); queryClient.setQueryData(globalAgentConfigQueryKey, loaded); } }) .catch(() => { if (!cancelled) { setIsLoading(false); setLoadError(true); } }); return () => { cancelled = true; }; }, [queryClient]); // Load baked build env once on mount. OSS builds return [] — the section // stays hidden. Failures are silently swallowed (non-critical display data). React.useEffect(() => { getBakedBuildEnv() .then(setBakedEnv) .catch(() => { // non-critical — leave bakedEnv empty }); }, []); const runtimesQuery = useAcpRuntimesQuery(); const selectableRuntimes = useSelectableAcpRuntimes(runtimesQuery.data ?? []); const sortedRuntimes = React.useMemo( () => sortPersonaRuntimes(selectableRuntimes), [selectableRuntimes], ); // An unset preferred runtime uses the same Buzz Agent-first fallback as // deployment. The rendered draft below carries that fallback forward so the // next user edit persists the visible harness instead of saving null. const selectedRuntime = React.useMemo(() => { const configuredRuntime = sortedRuntimes.find( (runtime) => runtime.id === config.preferred_runtime, ); return ( configuredRuntime ?? getDefaultPersonaRuntime(sortedRuntimes) ?? sortedRuntimes[0] ); }, [config.preferred_runtime, sortedRuntimes]); const renderedConfig = React.useMemo( () => config.preferred_runtime || !selectedRuntime ? config : { ...config, preferred_runtime: selectedRuntime.id }, [config, selectedRuntime], ); const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( selectedRuntime?.id ?? "", ); const harnessOptions = React.useMemo( () => sortedRuntimes.map((runtime) => ({ label: formatRuntimeOptionLabel(runtime), value: runtime.id, })), [sortedRuntimes], ); const configSurfaceLoading = isLoading || runtimesQuery.isLoading; const configSurfaceError = loadError || runtimesQuery.isError || (!configSurfaceLoading && sortedRuntimes.length === 0); function handleConfigChange(next: GlobalAgentConfig) { configRef.current = next; setConfig(next); setDirty(true); setSaveState("idle"); setSaveError(null); } function handleHarnessChange(runtimeId: string) { handleConfigChange(resetConfigForHarnessChange(config, runtimeId)); setConfigIsValid(false); setIsCustomModelEditing(false); setIsCustomProvider(false); } async function handleSave() { // Snapshot the config being submitted so we can detect edits that arrive // during the IPC round-trip and avoid clobbering the user's newer input. const submittedConfig = config; onSavingChange?.(true); setSaveState("saving"); setSaveError(null); try { const result = await setGlobalAgentConfig(submittedConfig); // Apply the backend's canonical config ONLY if nothing changed during the // IPC window. If the user edited, keep their newer value and leave dirty=true // so they can save again. setDirty(false) runs inside the updater so both // state updates batch into the same render (React 18 automatic batching). const savedCurrentDraft = configRef.current === submittedConfig; setConfig((current) => { if (!savedCurrentDraft) { // Mid-flight edit detected — do not overwrite newer user input. return current; } configRef.current = result.config; setDirty(false); return result.config; }); setRestartedCount(result.restarted_count); setFailedRestartCount(result.failed_restart_count); setSaveState("saved"); // Seed the shared TanStack Query cache with the canonical saved value so // all open dialogs (and any that open afterward) see the new config // synchronously — no second IPC round-trip needed. queryClient.setQueryData(globalAgentConfigQueryKey, result.config); if (savedCurrentDraft) onSaveSuccess?.(result); if (savedTimerRef.current) clearTimeout(savedTimerRef.current); // Partial restart failures require an explicit acknowledgment in the // overlay, so keep their result visible instead of fading it away. if (result.failed_restart_count === 0) { savedTimerRef.current = setTimeout(() => setSaveState("idle"), 2500); } } catch (err) { setSaveState("error"); setSaveError(typeof err === "string" ? err : "Couldn't save."); } finally { onSavingChange?.(false); } } const configFields = selectedRuntime ? ( ) : null; const progressiveFieldsTransition = shouldReduceMotion ? { duration: 0 } : PROGRESSIVE_FIELDS_TRANSITION; return (
{configSurfaceLoading ? (
Loading…
) : configSurfaceError ? (
Couldn't load agent defaults. Restart the app to try again.
) : ( <>
{flatLayout ? ( {configFields ? ( {configFields} ) : null} ) : ( configFields )} )} {/* Save bar */} {!configSurfaceLoading && !configSurfaceError && (
{saveState === "saved" && ( {restartedCount > 0 ? `Saved. Restarted ${restartedCount} agent${restartedCount === 1 ? "" : "s"}.${failedRestartCount > 0 ? ` ${failedRestartCount} couldn't restart — check the Agents page.` : ""}` : failedRestartCount > 0 ? `Saved. ${failedRestartCount} agent${failedRestartCount === 1 ? "" : "s"} couldn't restart — check the Agents page.` : "Saved."} )} {saveState === "error" && saveError && ( {saveError} )}
{secondaryAction}
)}
); }