Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions apps/desktop/src/app/shell/model-menu-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,15 @@ import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
$hideUnconfiguredProviders,
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
filterConfiguredProviders,
type ModelFamily,
modelVisibilityKey,
setHideUnconfiguredProviders,
setModelVisibilityOpen
} from '@/store/model-visibility'
import {
Expand Down Expand Up @@ -80,6 +83,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const currentReasoningEffort = useStore($currentReasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels)
const hideUnconfigured = useStore($hideUnconfiguredProviders)

const modelOptions = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
Expand Down Expand Up @@ -113,10 +117,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
[providers]
)

const pickerProviders = useMemo(
() => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [],
[providers]
)
const pickerProviders = useMemo(() => {
const base = providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? []

// Optionally hide providers the user hasn't set up (no usable credentials),
// always keeping the active provider so the current model can't vanish.
return filterConfiguredProviders(base, hideUnconfigured, optionsProvider)
}, [providers, hideUnconfigured, optionsProvider])

const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, pickerProviders),
Expand Down Expand Up @@ -360,6 +367,18 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
{copy.refreshModels}
</DropdownMenuItem>

<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={event => {
event.preventDefault()
setHideUnconfiguredProviders(!hideUnconfigured)
}}
>
<Codicon name="filter" size="0.75rem" />
<span className="min-w-0 flex-1 truncate">{copy.hideUnconfigured}</span>
{hideUnconfigured ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>

<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1982,6 +1982,7 @@ export const en: Translations = {
noModels: 'No models found',
editModels: 'Edit Models…',
refreshModels: 'Refresh Models',
hideUnconfigured: 'Hide Unconfigured Providers',
fast: 'Fast',
medium: 'Med'
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,7 @@ export interface Translations {
noModels: string
editModels: string
refreshModels: string
hideUnconfigured: string
fast: string
medium: string
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2150,6 +2150,7 @@ export const zh: Translations = {
noModels: '未找到模型',
editModels: '编辑模型…',
refreshModels: '刷新模型',
hideUnconfigured: '隐藏未配置的提供商',
fast: '快速',
medium: '中'
},
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/store/model-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
defaultVisibleKeys,
effectiveVisibleKeys,
emptyProviderSentinelKey,
filterConfiguredProviders,
isProviderSentinel,
modelVisibilityKey,
resolveVisibleKeys,
Expand Down Expand Up @@ -224,3 +225,42 @@ describe('resolveVisibleKeys', () => {
expect([...resolveVisibleKeys(new Set(), providers)]).toEqual([])
})
})

describe('filterConfiguredProviders', () => {
const authed = (slug: string, authenticated?: boolean): ModelOptionProvider => ({
authenticated,
models: [`${slug}-model`],
name: slug,
slug
})

const providers = [authed('deepseek', true), authed('openai', false), authed('nous', false)]

it('returns every provider unchanged when the toggle is off', () => {
const result = filterConfiguredProviders(providers, false)

expect(result.map(p => p.slug)).toEqual(['deepseek', 'openai', 'nous'])
// A fresh array is returned, never the caller's reference.
expect(result).not.toBe(providers)
})

it('drops providers whose credentials are explicitly missing when the toggle is on', () => {
const result = filterConfiguredProviders(providers, true)

expect(result.map(p => p.slug)).toEqual(['deepseek'])
})

it('treats a missing authenticated flag as configured (only explicit false is filtered)', () => {
const result = filterConfiguredProviders([authed('deepseek', true), authed('custom', undefined)], true)

expect(result.map(p => p.slug)).toEqual(['deepseek', 'custom'])
})

it('always keeps the current provider even when it is unconfigured', () => {
const result = filterConfiguredProviders(providers, true, 'openai')

// deepseek stays (authenticated), openai stays (it is the active provider),
// nous is dropped (unconfigured and not active).
expect(result.map(p => p.slug)).toEqual(['deepseek', 'openai'])
})
})
32 changes: 31 additions & 1 deletion apps/desktop/src/store/model-visibility.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { atom } from 'nanostores'

import { persistString, storedString } from '@/lib/storage'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import type { ModelOptionProvider } from '@/types/hermes'

const STORAGE_KEY = 'hermes.desktop.visible-models'
const HIDE_UNCONFIGURED_KEY = 'hermes.desktop.hide-unconfigured-providers'

/** Models shown per provider in the status-bar dropdown before the user has
* customized the list. Backend `models` are already relevance-ordered. */
Expand Down Expand Up @@ -99,6 +100,35 @@ export function setModelVisibilityOpen(open: boolean): void {
$modelVisibilityOpen.set(open)
}

/** When true, the picker hides providers without usable credentials — the
* canonical rows surfaced with a setup affordance that the user hasn't set up.
* Opt-in (default false) so the "show everything, incl. setup rows" behavior is
* unchanged for users who don't enable it. (#59483) */
export const $hideUnconfiguredProviders = atom<boolean>(storedBoolean(HIDE_UNCONFIGURED_KEY, false))

export function setHideUnconfiguredProviders(hide: boolean): void {
$hideUnconfiguredProviders.set(hide)
persistBoolean(HIDE_UNCONFIGURED_KEY, hide)
}

/** Drop providers with no usable credentials when `hide` is on. A missing
* `authenticated` flag is treated as configured — only an explicit `false`
* (unconfigured canonical rows) is filtered. The current provider is always
* kept so the active model never disappears from the picker. */
export function filterConfiguredProviders(
providers: readonly ModelOptionProvider[],
hide: boolean,
currentProviderSlug?: string
): ModelOptionProvider[] {
if (!hide) {
return [...providers]
}

return providers.filter(
provider => provider.authenticated !== false || provider.slug === currentProviderSlug
)
}

/** The default-visible key set: the curated top-N per provider. Used both as
* the dropdown fallback and to seed the Edit Models dialog. */
export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
Expand Down