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.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 03:52:33 -05:00
parent 9a6b69d9af
commit 9ffb35c3c7
3 changed files with 64 additions and 36 deletions

View file

@ -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 () => {

View file

@ -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 (
<DropdownMenuGroup className="py-0.5" key={slug}>
<DropdownMenuItem
className={cn(dropdownMenuSectionLabel, 'cursor-pointer hover:bg-(--ui-control-active-background)')}
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
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}
<span className="truncate">{group.provider.name}</span>
<DisclosureCaret className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100" open={!collapsed} size="0.625rem" />
</DropdownMenuItem>
{!collapsed &&
group.families.map(family => {

View file

@ -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({
<DialogTitle className="text-[0.8125rem]">{copy.title}</DialogTitle>
</DialogHeader>
<div className="px-3 py-1.5">
<div className="flex items-center gap-1.5 px-3 py-1.5">
<Search className="pointer-events-none size-3.5 shrink-0 text-muted-foreground/70" />
<input
autoFocus
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
@ -96,32 +107,49 @@ export function ModelVisibilityDialog({
return null
}
const allFamilies = collapseModelFamilies(provider.models ?? [])
const onCount = allFamilies.filter(family =>
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 (
<div className="py-0.5" key={provider.slug}>
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
{provider.name}
<div className="flex items-center gap-2 px-3 pb-0.5 pt-1">
<button
className="group/label flex w-full items-center gap-1 pb-0.5 pt-0.5 text-left text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) hover:bg-transparent"
onClick={() => toggleCollapsedProvider(provider.slug)}
type="button"
>
<span className="min-w-0 truncate">{provider.name}</span>
<DisclosureCaret
className="shrink-0 opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
size="0.625rem"
/>
</button>
<Checkbox checked={checkState} onCheckedChange={next => toggleProvider(provider, next !== false)} />
</div>
{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 (
<label
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
key={key}
>
<span className="min-w-0 flex-1 truncate">
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch
checked={visible.has(key)}
onCheckedChange={() => toggle(provider, family.id)}
size="xs"
/>
</label>
)
})}
return (
<label
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs"
key={key}
>
<span className="min-w-0 flex-1 truncate">
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} size="xs" />
</label>
)
})}
</div>
)
})