mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
da519ebc5c
commit
f52b6530ff
3 changed files with 167 additions and 8 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
34
apps/desktop/src/store/provider-collapse.ts
Normal file
34
apps/desktop/src/store/provider-collapse.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue