mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
9a6b69d9af
commit
9ffb35c3c7
3 changed files with 64 additions and 36 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue