From d4449c47e264166071cdb339d01fcc80bba502dc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:52:33 -0500 Subject: [PATCH 1/4] feat(picker): derive a newest-per-lab featured shortlist from models.dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregator providers (nous, openrouter) serve dozens of models across many labs. Add a featured=True enricher to build_models_payload that attaches a featured_models shortlist to each row: within every lab keep the newest _FEATURED_PER_LAB models by models.dev release_date, ranked among that row's own models — never against the current date, so the choice is stable as models age. Same-date ties fall back to curated list order. Single-lab providers get an empty list. Derived live from the models.dev catalog already loaded on this path; no hand-maintained allowlist. --- hermes_cli/inventory.py | 77 ++++++++++++++++++ tests/hermes_cli/test_inventory.py | 122 +++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index e1b9a6616666..4e95665d481f 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -120,6 +120,7 @@ def build_models_payload( canonical_order: bool = False, pricing: bool = False, capabilities: bool = False, + featured: bool = False, force_fresh_nous_tier: bool = False, refresh: bool = False, probe_custom_providers: bool = True, @@ -152,6 +153,13 @@ def build_models_payload( ``{model: {fast, reasoning}}`` so pickers can gate the model-options controls (fast toggle / reasoning) to what each model actually supports, instead of offering knobs the backend would reject. + - ``featured``: add a per-row ``featured_models`` list — the newest few + models per lab (by models.dev release_date, ranked within the row's own + models; see ``_FEATURED_PER_LAB``) for aggregator providers that serve + dozens of models across many labs. Pickers default their visible set to + these; the rest of ``models`` stays reachable via search / show-all. Empty + for single-lab providers (callers fall back to top-N). Derived live from + models.dev — no allowlist. - ``force_fresh_nous_tier``: bypass the short Nous free-tier cache when selecting Portal-recommended Nous models and applying tier gating. Keep this false for UI picker opens; explicit auth/model flows can opt in @@ -262,6 +270,8 @@ def build_models_payload( _apply_pricing(rows, force_fresh_nous_tier=force_fresh_nous_tier) if capabilities: _apply_capabilities(rows) + if featured: + _apply_featured(rows) return { "providers": rows, @@ -296,6 +306,7 @@ def build_model_options_payload( canonical_order=True, pricing=True, capabilities=True, + featured=True, refresh=refresh, probe_custom_providers=refresh, probe_current_custom_provider=not refresh, @@ -428,6 +439,72 @@ def _apply_capabilities(rows: list[dict]) -> None: row["capabilities"] = caps +# How many models per lab the picker features by default. Aggregator rows keep +# the newest N of each lab (by models.dev release_date) and hide the older tail +# behind search / show-all. 5 keeps a lab's current headliners without letting a +# prolific vendor (OpenAI's gpt-5.6-* family) flood the default view. +_FEATURED_PER_LAB = 5 + + +def _apply_featured(rows: list[dict]) -> None: + """Attach a ``featured_models`` shortlist to each aggregator provider row. + + Aggregator providers (nous, openrouter) serve dozens of models across many + labs, so a flat "top-N" default would drop whole labs from the picker. + Instead we surface the ``_FEATURED_PER_LAB`` newest models per lab (the + vendor segment of a ``vendor/model`` id), ranked by models.dev + ``release_date`` among that row's OWN models — never against the current + date, so the choice is stable as models age. Same-date ties (and labs whose + models lack a date) fall back to the row's curated order, which is already + flagship-first, so a lab keeps its headliners rather than an arbitrary slice. + + Derived live from the models.dev catalog already loaded on this path (same + source as pricing/capabilities) — there is no hand-maintained allowlist to + keep in sync. Non-aggregator providers (a single lab, local endpoints, + custom proxies) get an empty list and callers fall back to their existing + top-N behaviour; splitting one lab into a shortlist would just hide models. + """ + try: + from agent.models_dev import get_model_info + except Exception: + get_model_info = None # type: ignore[assignment] + + for row in rows: + slug = str(row.get("slug") or "").strip().lower() + models = row.get("models") or [] + + # Group models by lab; only multi-lab aggregators get a shortlist. + by_lab: dict[str, list[tuple[int, str, str]]] = {} + for pos, model in enumerate(models): + lab = model.split("/", 1)[0] if "/" in model else "" + if not lab: + # No vendor prefix → single-namespace provider, not an + # aggregator. Bail on the whole row (see below). + by_lab = {} + break + date = "" + if get_model_info is not None: + info = get_model_info(slug, model) or get_model_info("openrouter", model) + date = getattr(info, "release_date", "") if info else "" + by_lab.setdefault(lab, []).append((pos, date, model)) + + # A shortlist only makes sense when the row spans several labs. + if len(by_lab) < 2: + row["featured_models"] = [] + continue + + featured: list[str] = [] + for entries in by_lab.values(): + # Newest release_date first; earlier list position breaks ties and + # is the sole key when a lab has no dated models (all ""). Keep the + # newest _FEATURED_PER_LAB of each lab. + ranked = sorted(entries, key=lambda e: (e[1], -e[0]), reverse=True) + featured.extend(model for _pos, _date, model in ranked[:_FEATURED_PER_LAB]) + # Preserve the row's model order for stable rendering. + order = {m: i for i, m in enumerate(models)} + row["featured_models"] = sorted(featured, key=lambda m: order[m]) + + # ─── Internal: row post-processing ────────────────────────────────────── diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 6c262180b9d8..0a1314c426c3 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -1031,3 +1031,125 @@ def test_list_authenticated_providers_refresh_busts_cache(): assert clear.call_count == 0 model_switch.list_authenticated_providers(refresh=True) assert clear.call_count == 1 + + +# ─── _apply_featured (one-flagship-per-lab shortlist) ────────────────── + + +class _FakeInfo: + def __init__(self, release_date: str) -> None: + self.release_date = release_date + + +def _apply_featured_with_dates(rows, dates: dict[str, str]): + """Run _apply_featured with a deterministic models.dev stub.""" + from hermes_cli import inventory + + def _fake_get_model_info(provider, model): + return _FakeInfo(dates[model]) if model in dates else None + + with patch("agent.models_dev.get_model_info", side_effect=_fake_get_model_info): + inventory._apply_featured(rows) + + +def test_apply_featured_keeps_newest_n_per_lab(): + """Each lab keeps its newest _FEATURED_PER_LAB models by release_date; the + older tail is dropped. Uses a lab with more than N models to exercise the + cut.""" + from hermes_cli.inventory import _FEATURED_PER_LAB + + # One lab ("a") with N+2 dated models, plus a second lab so the row counts + # as a multi-lab aggregator. + a_models = [f"a/m{i}" for i in range(_FEATURED_PER_LAB + 2)] + rows = [{"slug": "nous", "models": [*a_models, "b/solo"]}] + # m0 newest … m{N+1} oldest (descending dates), b/solo dated in the middle. + dates = {f"a/m{i}": f"2026-{12 - i:02d}-01" for i in range(_FEATURED_PER_LAB + 2)} + dates["b/solo"] = "2026-01-01" + _apply_featured_with_dates(rows, dates) + + featured = rows[0]["featured_models"] + # Lab "a" keeps its newest N (m0..m{N-1}); the two oldest drop. "b" keeps its one. + assert featured == [*a_models[:_FEATURED_PER_LAB], "b/solo"] + assert f"a/m{_FEATURED_PER_LAB}" not in featured + assert f"a/m{_FEATURED_PER_LAB + 1}" not in featured + + +def test_apply_featured_keeps_whole_lab_when_under_the_cap(): + """A lab with <= _FEATURED_PER_LAB models keeps all of them.""" + rows = [ + { + "slug": "nous", + "models": ["anthropic/opus", "anthropic/haiku", "google/gemini"], + } + ] + _apply_featured_with_dates( + rows, + { + "anthropic/opus": "2026-07-01", + "anthropic/haiku": "2026-03-01", + "google/gemini": "2026-05-01", + }, + ) + # Both anthropic models (2 <= 5) and the single google model survive, in + # the row's original order. + assert rows[0]["featured_models"] == [ + "anthropic/opus", + "anthropic/haiku", + "google/gemini", + ] + + +def test_apply_featured_ranks_within_list_not_against_now(): + """The kept models are the newest *in the list*, even if every model is old + — the current date never enters the comparison.""" + rows = [{"slug": "nous", "models": ["a/one", "a/two", "b/three"]}] + _apply_featured_with_dates( + rows, + {"a/one": "2019-01-01", "a/two": "2020-01-01", "b/three": "2018-06-01"}, + ) + # Both "a" models kept (2 <= 5), ordered by the row; "b" kept. + assert rows[0]["featured_models"] == ["a/one", "a/two", "b/three"] + + +def test_apply_featured_tie_breaks_on_list_order(): + """Same release_date within a lab falls back to curated (earliest) order + when the cut has to choose.""" + from hermes_cli.inventory import _FEATURED_PER_LAB + + # N+1 same-dated models in lab "x" so exactly one must be dropped; the LAST + # one in list order loses the tie. + x_models = [f"x/m{i}" for i in range(_FEATURED_PER_LAB + 1)] + rows = [{"slug": "nous", "models": [*x_models, "y/solo"]}] + dates = {m: "2026-07-09" for m in x_models} + dates["y/solo"] = "2026-01-01" + _apply_featured_with_dates(rows, dates) + + featured = rows[0]["featured_models"] + # Earliest N in list order survive the tie; the last is dropped. + assert featured == [*x_models[:_FEATURED_PER_LAB], "y/solo"] + assert x_models[_FEATURED_PER_LAB] not in featured + + +def test_apply_featured_undated_lab_falls_back_to_list_order(): + """A lab whose models have no models.dev date keeps them in list order + (undated sorts last, ties broken by position), up to the per-lab cap.""" + rows = [{"slug": "nous", "models": ["a/first", "a/second", "b/only"]}] + _apply_featured_with_dates(rows, {"b/only": "2026-01-01"}) # a/* undated + # Both undated "a" models kept (2 <= 5), in list order; "b" kept. + assert rows[0]["featured_models"] == ["a/first", "a/second", "b/only"] + + +def test_apply_featured_empty_for_single_lab_provider(): + """A provider serving one lab is not an aggregator — no shortlist, so the + caller falls back to top-N instead of hiding models.""" + rows = [{"slug": "deepseek", "models": ["deepseek-v4-pro", "deepseek-v4-flash"]}] + _apply_featured_with_dates(rows, {}) + assert rows[0]["featured_models"] == [] + + +def test_apply_featured_empty_for_prefixless_models(): + """Models with no vendor/ prefix (ollama, custom endpoints) get no + shortlist — there are no labs to split on.""" + rows = [{"slug": "ollama", "models": ["qwen3:latest", "llama3.2:latest"]}] + _apply_featured_with_dates(rows, {}) + assert rows[0]["featured_models"] == [] From 64166f87baff684cb9a71478fab95fd1e6f81d0b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:52:33 -0500 Subject: [PATCH 2/4] feat(desktop): default the model picker to the featured shortlist expandProviderDefaults prefers a provider's featured_models when present and falls back to the existing top-N for providers that ship none (single-lab, local, custom). Only aggregators get curated, so exactly the providers with the everything-under-the-sun problem are trimmed; the rest are unchanged. Every non-featured model stays one search or Edit Models toggle away. --- .../src/store/model-visibility.test.ts | 103 ++++++++++++++++++ apps/desktop/src/store/model-visibility.ts | 56 +++++++++- apps/desktop/src/types/hermes.ts | 5 + 3 files changed, 161 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts index 042e2d4279ca..a63aede8aaf4 100644 --- a/apps/desktop/src/store/model-visibility.test.ts +++ b/apps/desktop/src/store/model-visibility.test.ts @@ -10,6 +10,7 @@ import { isProviderSentinel, modelVisibilityKey, resolveVisibleKeys, + setProviderVisibility, toggleModelVisibility } from './model-visibility' @@ -224,3 +225,105 @@ describe('resolveVisibleKeys', () => { expect([...resolveVisibleKeys(new Set(), providers)]).toEqual([]) }) }) + +describe('featured defaults', () => { + const featuredProvider = (slug: string, models: string[], featured_models: string[]): ModelOptionProvider => ({ + featured_models, + models, + name: slug, + slug + }) + + it('defaults to the featured shortlist when a provider publishes one', () => { + const nous = featuredProvider( + 'nous', + ['anthropic/opus', 'anthropic/haiku', 'google/gemini', 'x-ai/grok'], + ['anthropic/opus', 'google/gemini', 'x-ai/grok'] + ) + + const visible = defaultVisibleKeys([nous]) + + // Featured are visible; the non-featured model is hidden by default. + expect(visible.has(modelVisibilityKey('nous', 'anthropic/opus'))).toBe(true) + expect(visible.has(modelVisibilityKey('nous', 'google/gemini'))).toBe(true) + expect(visible.has(modelVisibilityKey('nous', 'x-ai/grok'))).toBe(true) + expect(visible.has(modelVisibilityKey('nous', 'anthropic/haiku'))).toBe(false) + }) + + it('falls back to top-N when a provider ships no featured list', () => { + const plain = provider('ollama', ['qwen3:latest', 'llama3.2:latest']) + + const visible = defaultVisibleKeys([plain]) + + // No featured_models → every model stays a default (top-N, N ≫ 2 here). + expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true) + expect(visible.has(modelVisibilityKey('ollama', 'llama3.2:latest'))).toBe(true) + }) + + it('ignores an empty featured list and falls back to top-N', () => { + const plain = featuredProvider('ollama', ['qwen3:latest', 'llama3.2:latest'], []) + + const visible = defaultVisibleKeys([plain]) + + expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true) + expect(visible.has(modelVisibilityKey('ollama', 'llama3.2:latest'))).toBe(true) + }) +}) + +describe('setProviderVisibility', () => { + const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])] + + it('enabling a provider makes every one of its models visible', () => { + // Start from a hidden-all openai; flip it on. + const stored = new Set([emptyProviderSentinelKey('openai')]) + + const next = setProviderVisibility(stored, providers, 'openai', true) + + const visible = effectiveVisibleKeys(next, providers) + expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true) + expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(true) + // Sentinel is cleared. + expect(next.has(emptyProviderSentinelKey('openai'))).toBe(false) + }) + + it('disabling a provider hides all its models and records the sentinel', () => { + const next = setProviderVisibility(null, providers, 'openai', false) + + expect(next.has(emptyProviderSentinelKey('openai'))).toBe(true) + const visible = effectiveVisibleKeys(next, providers) + expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false) + expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false) + }) + + it('leaves other providers untouched (their sentinels survive)', () => { + const stored = new Set([emptyProviderSentinelKey('nous')]) + + // Turn openai fully on; nous must stay hidden. + const next = setProviderVisibility(stored, providers, 'openai', true) + + expect(next.has(emptyProviderSentinelKey('nous'))).toBe(true) + const visible = effectiveVisibleKeys(next, providers) + expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false) + expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true) + }) + + it('round-trips: enable then disable returns to a clean hidden-all', () => { + const enabled = setProviderVisibility(null, providers, 'openai', true) + const disabled = setProviderVisibility(enabled, providers, 'openai', false) + + expect(disabled.has(emptyProviderSentinelKey('openai'))).toBe(true) + // No stray real keys left for the provider. + expect([...disabled].some(k => k.startsWith('openai::') && !isProviderSentinel(k))).toBe(false) + }) + + it('collapses model families to one key per family when enabling', () => { + // A base + its -fast sibling collapse to a single family row/key. + const ps = [provider('nous', ['model', 'model-fast'])] + + const next = setProviderVisibility(null, ps, 'nous', true) + + expect(next.has(modelVisibilityKey('nous', 'model'))).toBe(true) + // The -fast sibling is represented by its base family, not its own key. + expect(next.has(modelVisibilityKey('nous', 'model-fast'))).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts index a6fd9a40a18f..0fd02dcbac65 100644 --- a/apps/desktop/src/store/model-visibility.ts +++ b/apps/desktop/src/store/model-visibility.ts @@ -111,13 +111,21 @@ export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): S return keys } -/** Add a provider's curated default model keys (top-N collapsed families) to - * `target`. Shared by `defaultVisibleKeys` and `resolveVisibleKeys` so the +/** Add a provider's curated default model keys to `target`. Prefers the + * backend's `featured_models` shortlist (one flagship per lab) for aggregator + * providers that would otherwise flood the default view with dozens of models; + * 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 { const families = collapseModelFamilies(provider.models ?? []) - for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) { + const featured = provider.featured_models ?? [] + const defaults = featured.length + ? families.filter(family => featured.includes(family.id)) + : families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER) + + for (const family of defaults) { target.add(modelVisibilityKey(provider.slug, family.id)) } } @@ -209,3 +217,45 @@ export function toggleModelVisibility( return next } + +/** Compute the next persisted visibility set when a provider's master switch is + * flipped. `visible=true` enables every one of the provider's collapsed model + * families (and clears its hide-all sentinel); `visible=false` removes them all + * and records the sentinel so the defaults are not silently re-expanded. + * Seeds from `resolveVisibleKeys` so other providers' state (including their + * sentinels) survives the persist, mirroring `toggleModelVisibility`. */ +export function setProviderVisibility( + stored: Set | null, + providers: readonly ModelOptionProvider[], + providerSlug: string, + visible: boolean +): Set { + const next = resolveVisibleKeys(stored, providers) + const sentinel = emptyProviderSentinelKey(providerSlug) + const provider = providers.find(p => p.slug === providerSlug) + const families = collapseModelFamilies(provider?.models ?? []) + + // Drop every existing entry for this provider (real keys + sentinel); we + // rebuild its state from scratch below. + for (const key of [...next]) { + if (key.startsWith(`${providerSlug}::`)) { + next.delete(key) + } + } + + if (visible) { + for (const family of families) { + next.add(modelVisibilityKey(providerSlug, family.id)) + } + + // A provider with zero models can't be "all on" — leave it empty rather + // than stranding a sentinel that reads as an explicit hide-all. + if (families.length === 0) { + next.delete(sentinel) + } + } else { + next.add(sentinel) + } + + return next +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 8857bca43277..a819a08fed66 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -363,6 +363,11 @@ export interface ModelOptionProvider { slug: string total_models?: number warning?: string + /** Curated shortlist (one flagship per lab) the picker shows by default for + * aggregator providers that serve dozens of models across many labs. Empty + * for providers with no manifest entry — the picker falls back to top-N. + * The rest of `models` stays reachable via search / Edit Models. */ + featured_models?: string[] /** True when the provider has usable credentials. False for canonical * providers surfaced by `include_unconfigured` that the user hasn't set up * yet — render these with a setup affordance instead of hiding them. */ From 9a6b69d9af5af8de59ba390d19271d7da7149dfa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:52:33 -0500 Subject: [PATCH 3/4] feat(ui): indeterminate support on the shared Checkbox Add data-[state=indeterminate] styling (same primary fill as checked) and a dash glyph that shows when the root is in the indeterminate state, so a partial select-all reads as a dash rather than a full check. --- apps/desktop/src/components/ui/checkbox.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/ui/checkbox.tsx b/apps/desktop/src/components/ui/checkbox.tsx index 2e6b24256b25..357560fd2cb8 100644 --- a/apps/desktop/src/components/ui/checkbox.tsx +++ b/apps/desktop/src/components/ui/checkbox.tsx @@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps - + + ) From 9ffb35c3c7bd1d6be4fd6413c437cc4dec562cb9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:52:33 -0500 Subject: [PATCH 4/4] feat(desktop): collapsible providers + select-all + search in Edit Models Mirror the picker dropdown's provider collapse into the Edit Models dialog so curating is one click per provider instead of scrolling through 30 models. Each provider header is a full-width clickable label (same style as the composer context-menu labels) with a DisclosureCaret next to the text and a select-all Checkbox (indeterminate when partial). Model rows stay Switches. The dropdown's collapse is fixed too: the current provider is now collapsible (was forced open), and the label style matches the rest of the app. Adds a search icon to the dialog input matching every other search field. --- .../src/app/shell/model-menu-panel.test.tsx | 9 ++- .../src/app/shell/model-menu-panel.tsx | 17 ++--- .../components/model-visibility-dialog.tsx | 74 +++++++++++++------ 3 files changed, 64 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index e68d3b99a077..7da7c4af3d32 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -178,7 +178,7 @@ describe('ModelMenuPanel provider collapse', () => { }) }) - it('auto-expands the active provider even when collapsed', async () => { + it('collapses the active provider too (no forced auto-expand)', async () => { $currentProvider.set('deepseek') $currentModel.set('deepseek-v4-pro') const { content } = renderPanel() @@ -186,8 +186,11 @@ describe('ModelMenuPanel provider collapse', () => { const header = await content.findByText('DeepSeek') fireEvent.click(header) - // Should still show models because it's the active provider - expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull() + // The current provider is collapsible like any other — clicking its header + // hides its models rather than forcing them to stay open. + await vi.waitFor(() => { + expect(content.queryByText('Deepseek V4 Pro')).toBeNull() + }) }) it('bypasses collapse when search is active', async () => { diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 883b9c18063e..5fd4c9691c8a 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useMemo, useState } from 'react' import { useSessionView } from '@/app/chat/session-view' import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { DropdownMenuGroup, DropdownMenuItem, @@ -18,7 +19,6 @@ import { import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' -import { ChevronDown, ChevronRight } from '@/lib/icons' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label' import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' @@ -244,25 +244,22 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re {groups.map(group => { const slug = group.provider.slug - // Collapsed when stored + no active search + not the current provider. - const collapsed = collapsedProviders.includes(slug) && !search && slug !== optionsProvider + // Collapsed when the user stored it (and not while searching, which + // spans every model regardless of collapse state). + const collapsed = collapsedProviders.includes(slug) && !search return ( { event.preventDefault() toggleCollapsedProvider(slug) }} textValue="" > - {collapsed ? ( - - ) : ( - - )} - {group.provider.name} + {group.provider.name} + {!collapsed && group.families.map(family => { diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 764239021e84..995e1d9adbda 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -3,11 +3,14 @@ import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Switch } from '@/components/ui/switch' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' +import { Search } from '@/lib/icons' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' import { normalize } from '@/lib/text' @@ -16,9 +19,11 @@ import { collapseModelFamilies, effectiveVisibleKeys, modelVisibilityKey, + setProviderVisibility, setVisibleModels, toggleModelVisibility } from '@/store/model-visibility' +import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' interface ModelVisibilityDialogProps { @@ -42,6 +47,7 @@ export function ModelVisibilityDialog({ const copy = t.modelVisibility const [search, setSearch] = useState('') const stored = useStore($visibleModels) + const collapsedProviders = useStore($collapsedProviders) const modelOptions = useQuery({ queryKey: modelOptionsQueryKey(profile, sessionId), @@ -60,6 +66,10 @@ export function ModelVisibilityDialog({ setVisibleModels(toggleModelVisibility($visibleModels.get(), providers, provider.slug, model)) } + const toggleProvider = (provider: ModelOptionProvider, next: boolean) => { + setVisibleModels(setProviderVisibility($visibleModels.get(), providers, provider.slug, next)) + } + const q = normalize(search) const matches = (provider: ModelOptionProvider, model: string) => @@ -72,7 +82,8 @@ export function ModelVisibilityDialog({ {copy.title} -
+
+ + visible.has(modelVisibilityKey(provider.slug, family.id)) + ).length + const checkState = onCount === 0 ? false : onCount === allFamilies.length ? true : 'indeterminate' + + const collapsed = collapsedProviders.includes(provider.slug) && !q + return (
-
- {provider.name} +
+ + toggleProvider(provider, next !== false)} />
- {models.map(family => { - const { name, tag } = modelDisplayParts(family.id) - const key = modelVisibilityKey(provider.slug, family.id) + {!collapsed && + models.map(family => { + const { name, tag } = modelDisplayParts(family.id) + const key = modelVisibilityKey(provider.slug, family.id) - return ( - - ) - })} + return ( + + ) + })}
) })