From 9ffb35c3c7bd1d6be4fd6413c437cc4dec562cb9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:52:33 -0500 Subject: [PATCH] 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 ( + + ) + })}
) })