diff --git a/web/src/sections/model-selector/ModelSelector.tsx b/web/src/sections/model-selector/ModelSelector.tsx index 61c8b8ba169..4d60a70f86d 100644 --- a/web/src/sections/model-selector/ModelSelector.tsx +++ b/web/src/sections/model-selector/ModelSelector.tsx @@ -42,14 +42,20 @@ export interface ModelSelectorProps { reasoningManager?: ReasoningManager; disabled?: boolean; + /** * When true, a "Global Default Model" entry is prepended to the list. * Selecting it calls onChange with GLOBAL_DEFAULT_LLM_OPTION * (modelConfigurationId === null), which callers should treat as "clear." */ includeGlobalDefault?: boolean; + /** Which side of the trigger the popover prefers to open on. */ side?: "top" | "bottom" | "left" | "right"; + + /** Optional controlled open state for the popover. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; } export default function ModelSelector({ @@ -64,6 +70,8 @@ export default function ModelSelector({ disabled = false, includeGlobalDefault = false, side = "top", + open: controlledOpen, + onOpenChange, }: ModelSelectorProps) { // Unscoped by default. An agent narrows the model list, but only a chat has // an agent. The admin and settings pages that embed this picker have none, @@ -76,16 +84,34 @@ export default function ModelSelector({ defaultText, isLoading: allProvidersLoading, } = useLLMProviders(); + const llmProviders = providerOptions ?? allProviderOptions ?? []; const isLoading = providerOptions === undefined && allProvidersLoading; - const [open, setOpen] = useState(false); + + const [internalOpen, setInternalOpen] = useState(false); + + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange] + ); + const scrollContainerRef = useRef(null); // Resolve the currently selected option from the ID const currentOption = useMemo(() => { if (value == null || !llmProviders) return null; + for (const provider of llmProviders) { const mc = provider.model_configurations.find((m) => m.id === value); + if (mc) { return { provider: provider.provider, @@ -94,17 +120,24 @@ export default function ModelSelector({ }; } } + return null; }, [value, llmProviders]); // When no model is explicitly selected, fall back to showing the global default. const defaultModelOption = useMemo(() => { if (!defaultText || !llmProviders) return null; - const provider = llmProviders.find((p) => p.id === defaultText.provider_id); + + const provider = llmProviders.find( + (p) => p.id === defaultText.provider_id + ); + const mc = provider?.model_configurations.find( (m) => m.name === defaultText.model_name ); + if (!mc || !provider) return null; + return { provider: provider.provider, modelName: mc.name, @@ -118,6 +151,7 @@ export default function ModelSelector({ const isSelected = useCallback( (option: LLMOption) => { if (option === GLOBAL_DEFAULT_LLM_OPTION) return value === null; + return option.modelConfigurationId != null ? option.modelConfigurationId === value : option.provider === currentOption?.provider && @@ -129,9 +163,9 @@ export default function ModelSelector({ const handleSelect = useCallback( (option: LLMOption) => { onChange(option); - setOpen(false); + handleOpenChange(false); }, - [onChange] + [onChange, handleOpenChange] ); const modelDetail = useModelDetailManagers( @@ -144,7 +178,7 @@ export default function ModelSelector({ : getModelIcon("", ""); return ( - +
{renderTrigger ? ( diff --git a/web/src/views/admin/LanguageModelsPage.tsx b/web/src/views/admin/LanguageModelsPage.tsx index 55d5f598549..375014dee39 100644 --- a/web/src/views/admin/LanguageModelsPage.tsx +++ b/web/src/views/admin/LanguageModelsPage.tsx @@ -98,17 +98,24 @@ interface ExistingProviderCardProps { provider: LLMProviderView; isDefault: boolean; isLastProvider: boolean; + onBeforeOpen?: () => void; } function ExistingProviderCard({ provider, isDefault, isLastProvider, + onBeforeOpen, }: ExistingProviderCardProps) { const { mutate } = useSWRConfig(); const [isOpen, setIsOpen] = useState(false); const deleteModal = useCreateModal(); + const handleOpen = () => { + onBeforeOpen?.(); + setIsOpen(true); + }; + const handleDelete = async () => { try { await deleteLlmProvider(provider.id, isLastProvider); @@ -178,7 +185,7 @@ function ExistingProviderCard({ state="filled" padding={2} rounding="lg" - onClick={() => setIsOpen(true)} + onClick={handleOpen} > { e.stopPropagation(); + onBeforeOpen?.(); deleteModal.toggle(true); }} /> @@ -210,7 +218,7 @@ function ExistingProviderCard({ aria-label={`Edit ${providerDisplayName(provider)}`} onClick={(e) => { e.stopPropagation(); - setIsOpen(true); + handleOpen(); }} />
@@ -229,21 +237,28 @@ function ExistingProviderCard({ interface NewProviderCardProps { providerName: string; isFirstProvider: boolean; + onBeforeOpen?: () => void; } function NewProviderCard({ providerName, isFirstProvider, + onBeforeOpen, }: NewProviderCardProps) { const [isOpen, setIsOpen] = useState(false); const { icon, productName, companyName, Modal } = getProvider(providerName); + const handleOpen = () => { + onBeforeOpen?.(); + setIsOpen(true); + }; + return ( setIsOpen(true)} + onClick={handleOpen} > { e.stopPropagation(); - setIsOpen(true); + handleOpen(); }} > Connect @@ -266,7 +281,10 @@ function NewProviderCard({ } /> {isOpen && ( - + )} ); @@ -278,25 +296,35 @@ function NewProviderCard({ interface NewCustomProviderCardProps { isFirstProvider: boolean; + onBeforeOpen?: () => void; } function NewCustomProviderCard({ isFirstProvider, + onBeforeOpen, }: NewCustomProviderCardProps) { const [isOpen, setIsOpen] = useState(false); const { icon, productName, companyName, Modal } = getProvider("custom"); + const handleOpen = () => { + onBeforeOpen?.(); + setIsOpen(true); + }; + return ( <> {isOpen && ( - + )} setIsOpen(true)} + onClick={handleOpen} > { e.stopPropagation(); - setIsOpen(true); + handleOpen(); }} > Set Up @@ -334,6 +362,8 @@ export default function LanguageModelsPage() { const isConfigurationDisabled = usePHFeatureFlag( PHFeatureFlag.LANGUAGE_MODEL_CONFIGURATION_DISABLED ); + const [isDefaultModelSelectorOpen, setIsDefaultModelSelectorOpen] = + useState(false); // Resolve the current default to a model_configuration_id for ModelSelector const defaultModelConfigId = useMemo(() => { @@ -412,6 +442,8 @@ export default function LanguageModelsPage() { } }} side="bottom" + open={isDefaultModelSelectorOpen} + onOpenChange={setIsDefaultModelSelectorOpen} /> @@ -444,6 +476,9 @@ export default function LanguageModelsPage() { provider={provider} isDefault={defaultText?.provider_id === provider.id} isLastProvider={sortedProviders.length === 1} + onBeforeOpen={() => + setIsDefaultModelSelectorOpen(false) + } /> ))} @@ -492,10 +527,18 @@ export default function LanguageModelsPage() { key={name} providerName={name} isFirstProvider={isFirstProvider} + onBeforeOpen={() => + setIsDefaultModelSelectorOpen(false) + } /> ))} {group.includeCustom && ( - + + setIsDefaultModelSelectorOpen(false) + } + /> )}