diff --git a/desktop/src/renderer/src/pages/settings/BasicSettings.tsx b/desktop/src/renderer/src/pages/settings/BasicSettings.tsx index b2ff37a6..1057e043 100644 --- a/desktop/src/renderer/src/pages/settings/BasicSettings.tsx +++ b/desktop/src/renderer/src/pages/settings/BasicSettings.tsx @@ -170,12 +170,16 @@ const BasicSettings: React.FC = ({ baseUrl, onLangChange, on return !!config?.api_keys?.[f] } + // Only list configured providers (built-in or custom). Unconfigured vendors + // have no usable credentials, so showing them — flagged "unconfigured" — is + // just noise. Keep the current selection so a saved value never disappears. const providerIds = config?.providers ? Object.keys(config.providers) : [] - const providerOptions = providerIds.map((id) => ({ - value: id, - label: localizedLabel(providerMeta(id)?.label) || id, - hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'), - })) + const providerOptions = providerIds + .filter((id) => isConfigured(id) || id === provider) + .map((id) => ({ + value: id, + label: localizedLabel(providerMeta(id)?.label) || id, + })) const currentMeta = providerMeta(provider) const currentUnconfigured = !!provider && !isConfigured(provider) const modelOptions = [ diff --git a/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx b/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx index 2da063df..d5486e83 100644 --- a/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx +++ b/desktop/src/renderer/src/pages/settings/CapabilityCard.tsx @@ -46,24 +46,26 @@ const CapabilityCard: React.FC = ({ const [customModel, setCustomModel] = useState('') const [showCustom, setShowCustom] = useState(false) - // A provider is configured when it has credentials (custom providers always - // carry their own). Unconfigured ones stay selectable but are flagged so the - // user is guided to set up the API key. + // A provider is configured when it has credentials (a custom provider counts + // only once it actually carries a name/key, not as an empty placeholder). const isConfigured = (id: string): boolean => { const p = data?.providers?.find((x) => x.id === id) if (!p) return true return p.configured || (p.is_custom && !!p.custom_name) } + // Only surface providers that are actually configured (built-in or custom). + // An unconfigured vendor has no usable credentials, so listing it — and + // flagging it "unconfigured" — only adds noise. The currently-selected + // provider is always kept so a saved value never silently disappears. const providerOptions: DropdownOption[] = useMemo(() => { - const opts = (state.providers || []).map((id) => ({ - value: id, - label: providerLabel(data, id), - hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'), - })) + const opts = (state.providers || []) + .filter((id) => isConfigured(id) || id === provider) + .map((id) => ({ value: id, label: providerLabel(data, id) })) if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts] return opts - }, [state.providers, data, allowAuto, autoLabel]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.providers, data, allowAuto, autoLabel, provider]) const currentUnconfigured = !!provider && !isConfigured(provider) diff --git a/desktop/src/renderer/src/pages/settings/ModelsTab.tsx b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx index f3966373..7ad8ac9d 100644 --- a/desktop/src/renderer/src/pages/settings/ModelsTab.tsx +++ b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx @@ -294,8 +294,11 @@ const VendorModal: React.FC<{ const open = !!provider || addMode // In add-mode the user first picks a built-in provider; that selection - // becomes the effective provider whose key/base fields we edit. - const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers]) + // becomes the effective provider whose key/base fields we edit. Exclude ALL + // custom providers (named or empty placeholder): custom vendors are added via + // the single "custom vendor" option below, so an empty custom placeholder must + // not show up here as a second, duplicate custom entry. + const builtins = useMemo(() => data.providers.filter((p) => !p.is_custom), [data.providers]) const firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0] const [pickId, setPickId] = useState('') diff --git a/desktop/src/renderer/src/pages/settings/primitives.tsx b/desktop/src/renderer/src/pages/settings/primitives.tsx index cfd9b764..95247eda 100644 --- a/desktop/src/renderer/src/pages/settings/primitives.tsx +++ b/desktop/src/renderer/src/pages/settings/primitives.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useLayoutEffect } from 'react' +import { createPortal } from 'react-dom' import { ChevronDown } from 'lucide-react' import { t } from '../../i18n' @@ -50,13 +51,42 @@ export const Dropdown: React.FC<{ }> = ({ value, display, placeholder, options, disabled, onChange }) => { const [open, setOpen] = useState(false) const ref = useRef(null) + const menuRef = useRef(null) + // The menu is rendered in a portal with fixed positioning so it's never + // clipped by an ancestor's `overflow` (e.g. a modal's scroll container). + const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null) + + const place = () => { + const el = ref.current + if (!el) return + const r = el.getBoundingClientRect() + setRect({ left: r.left, top: r.bottom + 4, width: r.width }) + } + + useLayoutEffect(() => { + if (open) place() + }, [open]) + useEffect(() => { - const h = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + if (!open) return + const onDown = (e: MouseEvent) => { + const target = e.target as Node + if (ref.current?.contains(target) || menuRef.current?.contains(target)) return + setOpen(false) } - document.addEventListener('mousedown', h) - return () => document.removeEventListener('mousedown', h) - }, []) + // Keep the fixed menu anchored to the trigger on scroll/resize by + // re-measuring — NOT closing. Closing on scroll makes the dropdown vanish + // the moment the user scrolls the settings page. + document.addEventListener('mousedown', onDown) + window.addEventListener('resize', place, true) + window.addEventListener('scroll', place, true) + return () => { + document.removeEventListener('mousedown', onDown) + window.removeEventListener('resize', place, true) + window.removeEventListener('scroll', place, true) + } + }, [open]) + const current = display ?? options.find((o) => o.value === value)?.label ?? '' return (
@@ -73,28 +103,35 @@ export const Dropdown: React.FC<{ {current || placeholder || '--'} - {open && ( -
- {options.length === 0 && ( -
{t('models_no_options')}
- )} - {options.map((o) => ( -
{ - onChange(o.value) - setOpen(false) - }} - className={`px-3 py-2 text-sm cursor-pointer transition-colors ${ - o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2' - }`} - > -
{o.label}
- {o.hint &&
{o.hint}
} -
- ))} -
- )} + {open && + rect && + createPortal( +
+ {options.length === 0 && ( +
{t('models_no_options')}
+ )} + {options.map((o) => ( +
{ + onChange(o.value) + setOpen(false) + }} + className={`px-3 py-2 text-sm cursor-pointer transition-colors ${ + o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2' + }`} + > +
{o.label}
+ {o.hint &&
{o.hint}
} +
+ ))} +
, + document.body + )}
) }