diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index c3e4dff50d..1db80a27c6 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -39,6 +39,7 @@ import { effectiveVisibleKeys, type ModelFamily, modelVisibilityKey, + seedKnownModels, setModelVisibilityOpen } from '@/store/model-visibility' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' @@ -268,6 +269,8 @@ export function ModelCatalogMenu({ // Resolve visibility HERE, against the catalog we actually fetched: an empty // provider list would otherwise resolve to an empty key set that reads as // "user hid everything" and blanks the menu on first open. + useEffect(() => seedKnownModels(pickerProviders), [pickerProviders]) + const shownKeys = useMemo( () => effectiveVisibleKeys(visibleModels, pickerProviders), [visibleModels, pickerProviders] diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index b830591228..e5bbbed42f 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -1,7 +1,7 @@ import type { ModelOptionProvider, ModelOptionsResult } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -21,6 +21,7 @@ import { collapseModelFamilies, effectiveVisibleKeys, modelVisibilityKey, + seedKnownModels, setProviderVisibility, setVisibleModels, toggleModelVisibility @@ -63,14 +64,16 @@ export function ModelVisibilityDialog({ [modelOptions.data] ) + useEffect(() => seedKnownModels(providers), [providers]) + const visible = effectiveVisibleKeys(stored, providers) const toggle = (provider: ModelOptionProvider, model: string) => { - setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model)) + setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model), providers) } const setProviderVisible = (provider: ModelOptionProvider, next: boolean) => { - setVisibleModels(setProviderVisibility($visibleModels.get(), providers, provider.slug, next)) + setVisibleModels(setProviderVisibility($visibleModels.get(), providers, provider.slug, next), providers) } const q = normalize(search) diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts index 7bb784e86d..76dba69f05 100644 --- a/apps/desktop/src/store/model-visibility.test.ts +++ b/apps/desktop/src/store/model-visibility.test.ts @@ -33,15 +33,41 @@ describe('model visibility', () => { expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(true) }) - it('does not re-add models from a provider that already has stored choices', () => { + it('does not re-add models the user already judged for a curated provider', () => { const stored = new Set([modelVisibilityKey('local-ollama', 'qwen3:latest')]) - const visible = effectiveVisibleKeys(stored, [provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])]) + const known = new Set([ + modelVisibilityKey('local-ollama', 'qwen3:latest'), + modelVisibilityKey('local-ollama', 'llama3.2:latest') + ]) + + const visible = effectiveVisibleKeys(stored, [provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])], known) expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true) expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false) }) + it('shows a model that appeared after the user curated its provider, unless the provider is hidden', () => { + // User curated claude-sub (kept sonnet, hid haiku) and hid all of nous; then a plugin update adds opus. + const stored = new Set([modelVisibilityKey('claude-sub', 'sonnet'), emptyProviderSentinelKey('nous')]) + + const known = new Set([ + modelVisibilityKey('claude-sub', 'sonnet'), + modelVisibilityKey('claude-sub', 'haiku'), + modelVisibilityKey('nous', 'hermes-4') + ]) + + const providers = [provider('claude-sub', ['sonnet', 'haiku', 'opus']), provider('nous', ['hermes-4', 'hermes-5'])] + const visible = effectiveVisibleKeys(stored, providers, known) + + expect(visible.has(modelVisibilityKey('claude-sub', 'opus'))).toBe(true) + expect(visible.has(modelVisibilityKey('claude-sub', 'haiku'))).toBe(false) + expect(visible.has(modelVisibilityKey('nous', 'hermes-5'))).toBe(false) + + // No snapshot yet (pre-upgrade store): nothing counts as new, hide choices stay verbatim. + expect(effectiveVisibleKeys(stored, providers, null).has(modelVisibilityKey('claude-sub', 'opus'))).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')]) diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts index c43eddbdb4..46cf36b0d7 100644 --- a/apps/desktop/src/store/model-visibility.ts +++ b/apps/desktop/src/store/model-visibility.ts @@ -5,6 +5,12 @@ import { persistString, storedString } from '@/lib/storage' const STORAGE_KEY = 'hermes.desktop.visible-models' +/** Every `provider::model` key the user has had a chance to judge — snapshotted + * each time the visible set is persisted. A model absent from here appeared + * AFTER the user last curated (plugin update, catalog refresh, new release), so + * it falls through to the curated default rule instead of defaulting to hidden. */ +const KNOWN_STORAGE_KEY = 'hermes.desktop.known-models' + /** Models shown per provider in the status-bar dropdown before the user has * customized the list. Backend `models` are already relevance-ordered. */ export const DEFAULT_VISIBLE_PER_PROVIDER = 50 @@ -68,8 +74,8 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[] return families } -function loadVisible(): Set | null { - const raw = storedString(STORAGE_KEY) +function loadKeySet(storageKey: string): Set | null { + const raw = storedString(storageKey) if (!raw) { return null @@ -86,13 +92,56 @@ function loadVisible(): Set | null { /** Explicit set of visible `provider::model` keys, or null when the user * hasn't customized — in which case the curated default applies. */ -export const $visibleModels = atom | null>(loadVisible()) +export const $visibleModels = atom | null>(loadKeySet(STORAGE_KEY)) + +/** Keys the user has seen, or null when nothing has been recorded yet (a fresh + * install, or a store written before the snapshot existed). */ +export const $knownModels = atom | null>(loadKeySet(KNOWN_STORAGE_KEY)) export const $modelVisibilityOpen = atom(false) -export function setVisibleModels(keys: Set): void { +/** Every collapsed-family key across `providers`. */ +function allFamilyKeys(providers: readonly ModelOptionProvider[]): Set { + const keys = new Set() + + for (const provider of providers) { + for (const family of collapseModelFamilies(provider.models ?? [])) { + keys.add(modelVisibilityKey(provider.slug, family.id)) + } + } + + return keys +} + +/** Persist the visible set and, when the current catalog is supplied, mark every + * model in it as judged so only models that appear later count as new. */ +export function setVisibleModels(keys: Set, providers: readonly ModelOptionProvider[] = []): void { $visibleModels.set(new Set(keys)) persistString(STORAGE_KEY, JSON.stringify([...keys])) + + if (providers.length === 0) { + return + } + + persistKnownModels(new Set([...($knownModels.get() ?? []), ...allFamilyKeys(providers)])) +} + +function persistKnownModels(known: Set): void { + $knownModels.set(known) + persistString(KNOWN_STORAGE_KEY, JSON.stringify([...known])) +} + +/** One-time adoption for a visible set persisted before the known snapshot + * existed: everything in the catalog at that moment counts as judged (the + * user's hide choices are honoured verbatim); only models that appear later are + * new. Never a running union — that would mark a newcomer judged on the very + * render that first shows it. Call when the catalog has loaded. */ +export function seedKnownModels(providers: readonly ModelOptionProvider[]): void { + if ($knownModels.get() !== null || $visibleModels.get() === null || providers.length === 0) { + return + } + + persistKnownModels(allFamilyKeys(providers)) } export function setModelVisibilityOpen(open: boolean): void { @@ -117,7 +166,11 @@ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): S * falls back to the top-N collapsed families when a provider ships no featured * list. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the * expansion rule lives in exactly one place. */ -function expandProviderDefaults(provider: ModelOptionProvider, target: Set): void { +function expandProviderDefaults( + provider: ModelOptionProvider, + target: Set, + admit: (key: string) => boolean = () => true +): void { const families = collapseModelFamilies(provider.models ?? []) const featured = provider.featured_models ?? [] @@ -127,7 +180,11 @@ function expandProviderDefaults(provider: ModelOptionProvider, target: Set | null, providers: readonly ModelOptionProvider[]): Set { + * emptied. Use `effectiveVisibleKeys` for display (sentinels stripped). + * + * A provider the user has curated still admits models absent from `known` + * (they arrived after the last curation) through the same default rule, so a + * plugin or catalog update never lands a model silently switched off. A + * provider the user hid outright (sentinel) stays hidden, new models included. + * With no snapshot yet (`known` null) nothing counts as new. */ +export function resolveVisibleKeys( + stored: Set | null, + providers: readonly ModelOptionProvider[], + known: Set | null = $knownModels.get() +): Set { if (!stored) { return defaultVisibleKeys(providers) } @@ -150,15 +217,17 @@ export function resolveVisibleKeys(stored: Set | null, providers: readon for (const provider of providers) { const providerPrefix = `${provider.slug}::` - const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix) && !isProviderSentinel(key)) - - const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug)) - - if (hasStoredProvider || hasSentinel) { + if (stored.has(emptyProviderSentinelKey(provider.slug))) { continue } - expandProviderDefaults(provider, next) + const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix) && !isProviderSentinel(key)) + + if (!hasStoredProvider) { + expandProviderDefaults(provider, next) + } else if (known) { + expandProviderDefaults(provider, next, key => !known.has(key)) + } } return next @@ -168,9 +237,10 @@ export function resolveVisibleKeys(stored: Set | null, providers: readon * set with bookkeeping sentinels stripped (they are not real models). */ export function effectiveVisibleKeys( stored: Set | null, - providers: readonly ModelOptionProvider[] + providers: readonly ModelOptionProvider[], + known: Set | null = $knownModels.get() ): Set { - const next = resolveVisibleKeys(stored, providers) + const next = resolveVisibleKeys(stored, providers, known) // Strip sentinel keys — they are bookkeeping, not real visibility entries. for (const key of [...next]) { @@ -191,10 +261,11 @@ export function toggleModelVisibility( stored: Set | null, providers: readonly ModelOptionProvider[], providerSlug: string, - model: string + model: string, + known: Set | null = $knownModels.get() ): Set { // `resolveVisibleKeys` always returns a fresh Set, so we can mutate it directly. - const next = resolveVisibleKeys(stored, providers) + const next = resolveVisibleKeys(stored, providers, known) const key = modelVisibilityKey(providerSlug, model) const sentinel = emptyProviderSentinelKey(providerSlug) @@ -229,9 +300,10 @@ export function setProviderVisibility( stored: Set | null, providers: readonly ModelOptionProvider[], providerSlug: string, - visible: boolean + visible: boolean, + known: Set | null = $knownModels.get() ): Set { - const next = resolveVisibleKeys(stored, providers) + const next = resolveVisibleKeys(stored, providers, known) const sentinel = emptyProviderSentinelKey(providerSlug) const provider = providers.find(p => p.slug === providerSlug) const families = collapseModelFamilies(provider?.models ?? [])