diff --git a/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx b/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx index eb19007ce4..7020070103 100644 --- a/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx @@ -6,12 +6,14 @@ * purely presentational and calls onConfigChange on every user edit. */ import * as React from "react"; +import { ChevronDown } from "lucide-react"; import type { BakedEnvEntry } from "@/shared/api/tauri"; import type { AcpRuntimeCatalogEntry, GlobalAgentConfig, } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { EnvVarsEditor } from "@/features/agents/ui/EnvVarsEditor"; import type { InheritedEnvRow } from "@/features/agents/ui/EnvVarsEditor"; import { @@ -22,11 +24,16 @@ import { AUTO_PROVIDER_DROPDOWN_VALUE, BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, + PERSONA_LLM_PROVIDER_OPTIONS, getPersonaProviderOptions, getProviderApiKeyEnvVar, requiredCredentialEnvKeys, + runtimeSupportsLlmProviderSelection, } from "@/features/agents/ui/personaDialogPickers"; -import { AgentModelField } from "@/features/agents/ui/personaProviderModelFields"; +import { + AgentDropdownSelect, + AgentModelField, +} from "@/features/agents/ui/personaProviderModelFields"; import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField"; import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery"; import { @@ -65,6 +72,30 @@ export type GlobalAgentConfigFieldsProps = { onCustomModelEditingChange: (value: boolean) => void; onIsCustomProviderChange: (value: boolean) => void; onValidityChange?: (valid: boolean) => void; + autoSelectModelOnProviderChange?: boolean; + disableModelSelectDuringDiscovery?: boolean; + effortPlaceholderLabel?: string; + effortLabel?: string; + hideUnconfiguredCredentialProviders?: boolean; + keepSelectedModelValueLabel?: boolean; + modelPlaceholderLabel?: string; + placeholderClassName?: string; + providerLabel?: string; + preserveCredentialEnvVarsOnProviderChange?: boolean; + requireProviderForModelAndEffort?: boolean; + selectClassName?: string; + showProviderField?: boolean; + showAdvancedFields?: boolean; + showCustomModelOption?: boolean; + showCustomProviderOption?: boolean; + showDescriptions?: boolean; + showEffortField?: boolean; + showProviderPlaceholderOption?: boolean; + showRequiredIndicators?: boolean; + showUnavailableEffortOptions?: boolean; + unstyled?: boolean; + useCustomSelect?: boolean; + useChevronSelectIcon?: boolean; }; export function GlobalAgentConfigFields({ @@ -77,12 +108,40 @@ export function GlobalAgentConfigFields({ onCustomModelEditingChange, onIsCustomProviderChange, onValidityChange, + autoSelectModelOnProviderChange = false, + disableModelSelectDuringDiscovery = true, + effortPlaceholderLabel, + effortLabel = "Thinking/effort", + hideUnconfiguredCredentialProviders = false, + keepSelectedModelValueLabel = false, + modelPlaceholderLabel = "Select model", + placeholderClassName, + providerLabel = "LLM provider", + preserveCredentialEnvVarsOnProviderChange = false, + requireProviderForModelAndEffort = false, + selectClassName, + showProviderField = true, + showAdvancedFields = true, + showCustomModelOption = true, + showCustomProviderOption = true, + showDescriptions = true, + showEffortField = true, + showProviderPlaceholderOption = true, + showRequiredIndicators = true, + showUnavailableEffortOptions = true, + unstyled = false, + useCustomSelect = false, + useChevronSelectIcon = false, }: GlobalAgentConfigFieldsProps) { const bakedProvider = React.useMemo( () => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null, [bakedEnv], ); - const effectiveProvider = config.provider?.trim() || bakedProvider || ""; + const selectedRuntimeId = selectedRuntime?.id ?? ""; + const providerFieldVisible = showProviderField; + const effectiveProvider = providerFieldVisible + ? config.provider?.trim() || bakedProvider || "" + : ""; const fallbackModel = React.useMemo( () => getGlobalModelFallback(bakedEnv, effectiveProvider, config.env_vars), [bakedEnv, config.env_vars, effectiveProvider], @@ -102,11 +161,24 @@ export function GlobalAgentConfigFields({ [bakedEnv], ); - const providerValue = config.provider ?? ""; - const providerForDiscovery = isCustomProvider ? "" : providerValue; - const credentialProvider = isCustomProvider ? "" : effectiveProvider; + const providerValue = providerFieldVisible ? (config.provider ?? "") : ""; + const providerForDiscovery = + providerFieldVisible && !isCustomProvider + ? providerValue || bakedProvider || "" + : ""; + const dependentFieldsDisabled = + providerFieldVisible && + requireProviderForModelAndEffort && + providerForDiscovery.trim().length === 0; + const credentialProvider = + providerFieldVisible && !isCustomProvider ? effectiveProvider : ""; + const credentialRuntimeId = runtimeSupportsLlmProviderSelection( + selectedRuntimeId, + ) + ? selectedRuntimeId + : "buzz-agent"; const requiredEnvKeys = requiredCredentialEnvKeys( - "buzz-agent", + credentialRuntimeId, credentialProvider, ); const apiKeyEnvVar = getProviderApiKeyEnvVar(credentialProvider); @@ -119,6 +191,13 @@ export function GlobalAgentConfigFields({ () => bakedEnv.map((entry) => entry.key), [bakedEnv], ); + const configuredCredentialKeys = React.useMemo(() => { + const keys = new Set(bakedEnvKeys); + for (const [key, value] of Object.entries(config.env_vars)) { + if (value.trim().length > 0) keys.add(key); + } + return keys; + }, [bakedEnvKeys, config.env_vars]); const apiKeyInherited = apiKeyEnvVar !== null && apiKeyValue.length === 0 && @@ -131,14 +210,67 @@ export function GlobalAgentConfigFields({ } = usePersonaModelDiscovery({ envVars: config.env_vars, isCustomProviderEditing: isCustomProvider, - modelFieldVisible: true, + modelFieldVisible: !dependentFieldsDisabled, open: true, provider: providerForDiscovery, selectedRuntime, }); + const autoSelectedModelScopeRef = React.useRef(null); + React.useEffect(() => { + if (!autoSelectModelOnProviderChange) return; + const trimmedProvider = providerForDiscovery.trim(); + if (trimmedProvider.length === 0 || isCustomProvider) { + autoSelectedModelScopeRef.current = null; + return; + } + if ((config.model ?? "").trim().length > 0) return; + if (modelDiscoveryLoading || discoveredModelOptions === null) return; + const selectionScope = `${selectedRuntimeId}:${trimmedProvider}`; + if (autoSelectedModelScopeRef.current === selectionScope) return; + + const firstModel = discoveredModelOptions.find( + (option) => option.id.trim().length > 0, + ); + if (!firstModel) return; + + autoSelectedModelScopeRef.current = selectionScope; + onCustomModelEditingChange(false); + onConfigChange({ ...config, model: firstModel.id }); + }, [ + autoSelectModelOnProviderChange, + config, + discoveredModelOptions, + isCustomProvider, + modelDiscoveryLoading, + onConfigChange, + onCustomModelEditingChange, + providerForDiscovery, + selectedRuntimeId, + ]); + const currentEffortForAutoClear = config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? ""; + React.useEffect(() => { + if (!dependentFieldsDisabled) return; + if ( + (config.model ?? "").trim().length === 0 && + currentEffortForAutoClear.length === 0 + ) { + return; + } + + const nextEnvVars = { ...config.env_vars }; + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + onCustomModelEditingChange(false); + onConfigChange({ ...config, env_vars: nextEnvVars, model: null }); + }, [ + config, + currentEffortForAutoClear, + dependentFieldsDisabled, + onConfigChange, + onCustomModelEditingChange, + ]); const { validValues: effortValidForAutoClear } = getProviderEffortConfig( config.provider ?? "", config.model ?? "", @@ -157,7 +289,9 @@ export function GlobalAgentConfigFields({ const previousApiKey = getProviderApiKeyEnvVar(effectiveProvider); if (value === CUSTOM_PROVIDER_DROPDOWN_VALUE) { const nextEnvVars = { ...config.env_vars }; - if (previousApiKey) delete nextEnvVars[previousApiKey]; + if (!preserveCredentialEnvVarsOnProviderChange && previousApiKey) { + delete nextEnvVars[previousApiKey]; + } onIsCustomProviderChange(true); onConfigChange({ ...config, env_vars: nextEnvVars, provider: null }); return; @@ -168,9 +302,14 @@ export function GlobalAgentConfigFields({ nextProvider ?? bakedProvider ?? "", ); const nextEnvVars = { ...config.env_vars }; - if (previousApiKey && previousApiKey !== nextApiKey) { + if ( + !preserveCredentialEnvVarsOnProviderChange && + previousApiKey && + previousApiKey !== nextApiKey + ) { delete nextEnvVars[previousApiKey]; } + const providerChanged = nextProvider !== (config.provider ?? null); onIsCustomProviderChange(false); onConfigChange({ @@ -178,7 +317,11 @@ export function GlobalAgentConfigFields({ env_vars: nextEnvVars, provider: nextProvider, model: - nextProvider === "relay-mesh" ? config.model || "auto" : config.model, + nextProvider === "relay-mesh" + ? config.model || "auto" + : autoSelectModelOnProviderChange && providerChanged + ? null + : config.model, }); } @@ -205,16 +348,38 @@ export function GlobalAgentConfigFields({ // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot // migration rewrites v1→v2. Hide the legacy v1 option so it is not offered // for new selections; OSS builds show it. - const hideProviderIds = React.useMemo( - () => - bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER") - ? BLOCK_BUILD_HIDDEN_PROVIDER_IDS - : new Set(), - [bakedEnvKeys], - ); + const hideProviderIds = React.useMemo(() => { + const hidden = new Set(); + if (bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER")) { + for (const providerId of BLOCK_BUILD_HIDDEN_PROVIDER_IDS) { + hidden.add(providerId); + } + } + if (hideUnconfiguredCredentialProviders) { + for (const option of PERSONA_LLM_PROVIDER_OPTIONS) { + const requiredKeys = requiredCredentialEnvKeys( + credentialRuntimeId, + option.id, + ); + if (requiredKeys.some((key) => !configuredCredentialKeys.has(key))) { + hidden.add(option.id); + } + } + } + if (selectedRuntimeId !== "buzz-agent") { + hidden.add("relay-mesh"); + } + return hidden; + }, [ + bakedEnvKeys, + configuredCredentialKeys, + credentialRuntimeId, + hideUnconfiguredCredentialProviders, + selectedRuntimeId, + ]); const providerOptions = getPersonaProviderOptions( providerValue, - "buzz-agent", + credentialRuntimeId, undefined, hideProviderIds, ); @@ -226,46 +391,127 @@ export function GlobalAgentConfigFields({ if (!bakedProvider) return null; return getBakedProviderInheritLabel(bakedProvider, providerOptions); }, [bakedProvider, providerOptions]); + const compactProviderZeroLabel = React.useMemo(() => { + if (bakedProvider) { + return ( + providerOptions.find((option) => option.id === bakedProvider)?.label ?? + bakedProvider + ); + } + return "Select a provider"; + }, [bakedProvider, providerOptions]); + const implicitEffortProvider = + selectedRuntimeId === "claude" + ? "anthropic" + : selectedRuntimeId === "codex" + ? "openai" + : ""; + const effortProvider = providerFieldVisible + ? (config.provider ?? "") + : implicitEffortProvider; const { validValues: effortValid, defaultValue: effortDefault } = - getProviderEffortConfig(config.provider ?? "", config.model ?? ""); + getProviderEffortConfig(effortProvider, config.model ?? ""); const currentEffort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? ""; - return ( - - {/* Provider field */} -
- - - {isCustomProvider ? ( - handleCustomProviderInput(e.target.value)} - placeholder="Custom provider ID" - value={providerValue} - /> - ) : null} -
+ const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3"; + const blockClassName = unstyled ? "" : "p-3"; + const fieldLabelClassName = unstyled ? "pl-3" : undefined; + const providerDropdownOptions = [ + ...providerOptions + .filter( + (opt) => + showProviderPlaceholderOption || + opt.id !== "" || + providerSelectValue === AUTO_PROVIDER_DROPDOWN_VALUE, + ) + .map((opt) => ({ + label: + opt.id === "" + ? showProviderPlaceholderOption + ? (providerZeroLabel ?? opt.label) + : compactProviderZeroLabel + : opt.label, + value: opt.id || AUTO_PROVIDER_DROPDOWN_VALUE, + })), + ...(showCustomProviderOption + ? [{ label: "Custom provider…", value: CUSTOM_PROVIDER_DROPDOWN_VALUE }] + : []), + ]; + const providerSelect = useCustomSelect ? ( + + ) : ( + + ); + + const content = ( + <> + {providerFieldVisible ? ( +
+ + {!useCustomSelect && useChevronSelectIcon ? ( +
+ {providerSelect} +
+ ) : ( + providerSelect + )} + {isCustomProvider ? ( + handleCustomProviderInput(e.target.value)} + placeholder="Custom provider ID" + value={providerValue} + /> + ) : null} +
+ ) : null} - {apiKeyEnvVar ? ( -
+ {showAdvancedFields && providerFieldVisible && apiKeyEnvVar ? ( +
+
{/* Thinking / Effort */} -
- { - const nextEnvVars = { ...config.env_vars }; - if (value === "") { - delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; - } else { - nextEnvVars[BUZZ_AGENT_THINKING_EFFORT] = value; + {showEffortField ? ( +
+ -
+ inheritedEffort={bakedEffort ?? undefined} + label={effortLabel} + labelClassName={fieldLabelClassName} + onChange={(value) => { + const nextEnvVars = { ...config.env_vars }; + if (value === "") { + delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT]; + } else { + nextEnvVars[BUZZ_AGENT_THINKING_EFFORT] = value; + } + onConfigChange({ ...config, env_vars: nextEnvVars }); + }} + placeholderClassName={placeholderClassName} + selectClassName={selectClassName} + showUnavailableOptions={showUnavailableEffortOptions} + testId="global-agent-thinking-effort-select" + useCustomSelect={useCustomSelect} + /> +
+ ) : null} - {/* Env vars */} -
- k !== BUZZ_AGENT_THINKING_EFFORT, - ), - )} - /> -
- + {showAdvancedFields ? ( + <> + {/* Env vars */} +
+ k !== BUZZ_AGENT_THINKING_EFFORT, + ), + )} + /> +
+ + ) : null} + ); + + if (unstyled) { + return
{content}
; + } + + return {content}; } diff --git a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx index 25af85ba9e..995bc9d6ef 100644 --- a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx +++ b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx @@ -7,7 +7,12 @@ */ import * as React from "react"; import { Input } from "@/shared/ui/input"; +import { cn } from "@/shared/lib/cn"; import type { EnvVarsValue } from "./EnvVarsEditor"; +import { + AgentDropdownSelect, + type AgentDropdownOption, +} from "./personaProviderModelFields"; import { BUZZ_AGENT_MAX_CONTEXT_TOKENS, BUZZ_AGENT_MAX_OUTPUT_TOKENS, @@ -32,21 +37,35 @@ import { */ export function EffortSelectField({ currentEffort, + disabled = false, + emptyOptionLabel, effortDefault, effortValid, + fieldClassName, htmlFor, inheritedEffort, inheritFallbackLabel, label, + labelClassName, onChange, + placeholderClassName, + selectClassName, + showUnavailableOptions = true, testId, + useCustomSelect = false, }: { /** Current effort value from env vars ("" = inherit). */ currentEffort: string; + /** Disable the dropdown. */ + disabled?: boolean; + /** Optional replacement label for the empty/inherit option. */ + emptyOptionLabel?: string; /** Semantic default for this provider/model combination, or null for manual-budget. */ effortDefault: string | null; /** Valid effort values for this provider/model. */ effortValid: ReadonlyArray; + /** Optional class override for the field wrapper. */ + fieldClassName?: string; /** `htmlFor` attribute for the label element. */ htmlFor: string; /** Inherited effort from a higher-precedence layer (shown in the Inherit option label). */ @@ -67,40 +86,85 @@ export function EffortSelectField({ inheritFallbackLabel?: string; /** Label text for the dropdown. */ label: string; + /** Optional class override for the label. */ + labelClassName?: string; /** Called when the user selects a new value. */ onChange: (value: string) => void; + /** Optional class override for placeholder text. */ + placeholderClassName?: string; + /** Optional class override for the select/trigger. */ + selectClassName?: string; + /** When false, hide effort values that are not valid for the provider/model. */ + showUnavailableOptions?: boolean; /** data-testid attribute for the select element. */ testId: string; + /** Render the polished app dropdown instead of the native select. */ + useCustomSelect?: boolean; }) { + const inheritLabel = inheritedEffort + ? `Inherit (${inheritedEffort})` + : effortDefault === null + ? "Inherit (default)" + : (inheritFallbackLabel ?? "Inherit"); + const effortOptions: AgentDropdownOption[] = [ + { label: emptyOptionLabel ?? inheritLabel, value: "" }, + ...BUZZ_AGENT_THINKING_EFFORT_VALUES.flatMap((v) => { + const isValid = (effortValid as readonly string[]).includes(v); + if (!showUnavailableOptions && !isValid) return []; + const isDefault = v === effortDefault; + return [ + { + disabled: !isValid, + label: isDefault ? `${v} (default)` : v, + value: v, + }, + ]; + }), + ]; + return ( -
-