diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 332b605ec74..d7147cc5c49 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -14,6 +14,8 @@ import { $visibleModels, collapseModelFamilies, effectiveVisibleKeys, + emptyProviderSentinelKey, + isProviderSentinel, modelVisibilityKey, setVisibleModels } from '@/store/model-visibility' @@ -61,10 +63,21 @@ export function ModelVisibilityDialog({ const toggle = (provider: ModelOptionProvider, model: string) => { const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers)) const key = modelVisibilityKey(provider.slug, model) + const sentinel = emptyProviderSentinelKey(provider.slug) if (next.has(key)) { next.delete(key) + + // Check if this was the last real model for this provider. + const remainingForProvider = [...next].some( + k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k) + ) + + if (!remainingForProvider) { + next.add(sentinel) + } } else { + next.delete(sentinel) next.add(key) } diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts index 483578460ad..ce78d1a6aa7 100644 --- a/apps/desktop/src/store/model-visibility.test.ts +++ b/apps/desktop/src/store/model-visibility.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest' import type { ModelOptionProvider } from '@/types/hermes' -import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility' +import { + effectiveVisibleKeys, + emptyProviderSentinelKey, + isProviderSentinel, + modelVisibilityKey +} from './model-visibility' const provider = (slug: string, models: string[]): ModelOptionProvider => ({ models, @@ -34,4 +39,48 @@ describe('model visibility', () => { expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true) expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false) }) + + it('preserves hidden-provider sentinel without re-adding defaults', () => { + // User explicitly hid all models for "nous" — sentinel marks this choice. + const stored = new Set([emptyProviderSentinelKey('nous')]) + + const visible = effectiveVisibleKeys(stored, [ + provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']), + provider('ollama', ['qwen3:latest']) + ]) + + expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(false) + expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false) + // Sentinel itself is stripped from the result. + expect(visible.has(emptyProviderSentinelKey('nous'))).toBe(false) + // Other providers still get defaults. + expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true) + }) + + it('restores model when toggling on after hiding all', () => { + // Simulates: user hid all "nous" models, then toggles one back on. + const stored = new Set([ + emptyProviderSentinelKey('nous'), + modelVisibilityKey('ollama', 'qwen3:latest') + ]) + + // After toggle: sentinel removed, one model added. + const afterToggle = new Set(stored) + afterToggle.delete(emptyProviderSentinelKey('nous')) + afterToggle.add(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b')) + + const visible = effectiveVisibleKeys(afterToggle, [ + provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']), + provider('ollama', ['qwen3:latest']) + ]) + + expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(true) + expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false) + }) + + it('sentinel key helper produces correct format', () => { + expect(emptyProviderSentinelKey('openai')).toBe('openai::') + expect(isProviderSentinel('openai::')).toBe(true) + expect(isProviderSentinel('openai::gpt-4o')).toBe(false) + }) }) diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts index 9fb555a4e70..de694fe3af5 100644 --- a/apps/desktop/src/store/model-visibility.ts +++ b/apps/desktop/src/store/model-visibility.ts @@ -13,6 +13,19 @@ export const DEFAULT_VISIBLE_PER_PROVIDER = 50 * that contain a single colon, e.g. `model:tag`). */ export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}` +/** Sentinel key suffix stored when the user explicitly hides ALL models for a + * provider. Distinguishes "user hid everything" from "never customized" so + * `effectiveVisibleKeys` does not re-add defaults for that provider. */ +export const EMPTY_PROVIDER_SENTINEL = '' + +/** Build the sentinel key for a provider whose last model was toggled off. */ +export const emptyProviderSentinelKey = (provider: string): string => + modelVisibilityKey(provider, EMPTY_PROVIDER_SENTINEL) + +/** Check whether a stored key is a provider-hidden sentinel. */ +export const isProviderSentinel = (key: string): boolean => + key.endsWith('::') + /** A model and its optional `…-fast` sibling, collapsed into one logical row. * `id` is the canonical (base) model; `fastId` is the fast variant if present. */ export interface ModelFamily { @@ -116,9 +129,12 @@ export function effectiveVisibleKeys( for (const provider of providers) { const providerPrefix = `${provider.slug}::` - const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix)) + const hasStoredProvider = [...stored].some( + key => key.startsWith(providerPrefix) && !isProviderSentinel(key) + ) + const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug)) - if (hasStoredProvider) { + if (hasStoredProvider || hasSentinel) { continue } @@ -129,5 +145,12 @@ export function effectiveVisibleKeys( } } + // Strip sentinel keys — they are bookkeeping, not real visibility entries. + for (const key of [...next]) { + if (isProviderSentinel(key)) { + next.delete(key) + } + } + return next }