feat(desktop): collapsible provider groups in model picker

Click a provider header (DEEPSEEK, GOOGLE, etc.) in the model picker dropdown
to collapse/expand its model list. State persists across sessions via localStorage.

- New provider-collapse store with persistentAtom<string[]> + stale-key pruning
- Provider headers become clickable DropdownMenuItems with chevron indicators
- textValue="" excludes headers from Radix typeahead (both reviewers flagged)
- Auto-expands the active provider so the checkmark is always visible
- Search bypasses collapse (typing shows all matching models)
- Keyboard accessible via Enter/Space on the header row
- Store double-read eliminated per cross-vendor review feedback

Reviewed-by: Flash + GPT-OSS cross-vendor review (all SHOULD-FIXs addressed)

Related: #60966 (different interaction model — hover-to-expand)
This commit is contained in:
David Metcalfe 2026-07-14 18:24:07 -07:00 committed by Teknium
parent da519ebc5c
commit f52b6530ff
3 changed files with 167 additions and 8 deletions

View file

@ -1,9 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu'
import { $activeSessionId, $currentModel, $currentProvider } from '@/store/session'
import { $collapsedProviders } from '@/store/provider-collapse'
import { ModelMenuPanel } from './model-menu-panel'
@ -26,11 +27,26 @@ vi.mock('@/hermes', () => ({
// REST config.
const MOA_PROVIDER = { models: ['default', 'BeastMode'], name: 'Mixture of Agents', slug: 'moa' }
const DEEPSEEK_PROVIDER = {
models: ['deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'],
name: 'DeepSeek',
slug: 'deepseek'
}
const GOOGLE_PROVIDER = {
models: ['gemini-3.1-pro', 'gemini-2.5-flash', 'gemini-2.5-pro'],
name: 'Google',
slug: 'google'
}
const MOCK_PROVIDERS = [DEEPSEEK_PROVIDER, GOOGLE_PROVIDER, MOA_PROVIDER]
beforeEach(() => {
$activeSessionId.set('runtime-1')
$currentModel.set('')
$currentProvider.set('')
getGlobalModelOptions.mockResolvedValue({ providers: [MOA_PROVIDER] })
$collapsedProviders.set([])
getGlobalModelOptions.mockResolvedValue({ providers: MOCK_PROVIDERS })
})
afterEach(() => {
@ -107,3 +123,78 @@ describe('ModelMenuPanel MoA presets', () => {
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa', sessionId: null })
})
})
describe('ModelMenuPanel provider collapse', () => {
it('shows all provider models by default (none collapsed)', async () => {
const { content } = renderPanel()
await content.findByText('DeepSeek')
expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull()
expect(content.queryByText('Deepseek Chat')).not.toBeNull()
})
it('collapses provider models when header is clicked', async () => {
const { content } = renderPanel()
const header = await content.findByText('DeepSeek')
fireEvent.click(header)
// Models should disappear but header stays
expect(content.queryByText('Deepseek V4 Pro')).toBeNull()
expect(content.queryByText('DeepSeek')).not.toBeNull()
})
it('expands provider models when header is clicked again', async () => {
const { content } = renderPanel()
const header = await content.findByText('DeepSeek')
// Collapse
fireEvent.click(header)
expect(content.queryByText('Deepseek V4 Pro')).toBeNull()
// Expand
fireEvent.click(header)
await vi.waitFor(() => {
expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull()
})
})
it('auto-expands the active provider even when collapsed', async () => {
$currentProvider.set('deepseek')
$currentModel.set('deepseek-v4-pro')
const { content } = renderPanel()
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()
})
it('bypasses collapse when search is active', async () => {
const { content } = renderPanel()
const header = await content.findByText('DeepSeek')
fireEvent.click(header)
expect(content.queryByText('Deepseek V4 Pro')).toBeNull()
// Type in the search bar (auto-focused by DropdownMenuSearch)
const input = screen.getByRole('textbox', { name: 'Search models' })
expect(input).not.toBeNull()
fireEvent.change(input, { target: { value: 'deepseek' } })
// Should show models — search bypasses collapse
await vi.waitFor(() => {
expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull()
})
})
it('toggles collapse via keyboard Enter on header', async () => {
const { content } = renderPanel()
const header = await content.findByText('DeepSeek')
// Radix DropdownMenuItem fires onSelect on Enter from the onKeyDown handler
fireEvent.keyDown(header.closest('[role="menuitem"]') ?? header, { key: 'Enter' })
expect(content.queryByText('Deepseek V4 Pro')).toBeNull()
})
})

View file

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
@ -27,6 +27,7 @@ import {
} from '@/lib/model-status-label'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronRight } from '@/lib/icons'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
$visibleModels,
@ -37,6 +38,7 @@ import {
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
import { $collapsedProviders, pruneStaleCollapsed, toggleCollapsedProvider } from '@/store/provider-collapse'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
@ -82,6 +84,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const currentReasoningEffort = useStore(view.$reasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels)
const collapsedProviders = useStore($collapsedProviders)
const modelOptions = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
@ -120,6 +123,14 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
[providers]
)
// Drop stale collapsed provider slugs when the provider list changes
// (e.g. after Refresh Models, plugin install/uninstall).
useEffect(() => {
if (pickerProviders.length > 0) {
pruneStaleCollapsed(pickerProviders.map(p => p.slug))
}
}, [pickerProviders])
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, pickerProviders),
[visibleModels, pickerProviders]
@ -240,10 +251,33 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
</DropdownMenuItem>
) : (
<div className="max-h-[max(150px,30dvh)] overflow-y-auto py-0.5">
{groups.map(group => (
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
{group.families.map(family => {
{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
return (
<DropdownMenuGroup className="py-0.5" key={slug}>
<DropdownMenuItem
className={cn(
dropdownMenuSectionLabel,
'cursor-pointer hover:bg-(--ui-control-active-background)'
)}
onSelect={event => {
event.preventDefault()
toggleCollapsedProvider(slug)
}}
textValue=""
>
{collapsed ?
<ChevronRight className="size-2.5 shrink-0" /> :
<ChevronDown className="size-2.5 shrink-0" />
}
{group.provider.name}
</DropdownMenuItem>
{!collapsed &&
group.families.map(family => {
// The active id may be the base or its -fast sibling; either
// way this one family row represents both.
const activeId =
@ -328,7 +362,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
)
})}
</DropdownMenuGroup>
))}
)})}
</div>
)}

View file

@ -0,0 +1,34 @@
import { Codecs, persistentAtom } from '@/lib/persisted'
const STORAGE_KEY = 'hermes.desktop.collapsed-providers'
/** Set of provider slugs whose model groups are currently collapsed in the
* model picker dropdown. Persisted to localStorage globally this is a
* presentation-layer preference, not a per-profile setting. */
export const $collapsedProviders = persistentAtom<string[]>(
STORAGE_KEY,
[],
Codecs.stringArray
)
/** Toggle a provider slug in/out of the collapsed set. */
export function toggleCollapsedProvider(slug: string): void {
const current = $collapsedProviders.get()
$collapsedProviders.set(
current.includes(slug)
? current.filter(s => s !== slug)
: [...current, slug]
)
}
/** Remove slugs from the collapsed set that no longer match any current
* provider. Call this after provider list changes (e.g. Refresh Models,
* plugin install/uninstall) to prevent unbounded growth. */
export function pruneStaleCollapsed(slugs: string[]): void {
const valid = new Set(slugs)
const next = $collapsedProviders.get().filter(s => valid.has(s))
if (next.length !== $collapsedProviders.get().length) {
$collapsedProviders.set(next)
}
}