Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/desktop/src/app/shell/model-menu-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,16 +178,19 @@ 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()

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 () => {
Expand Down
17 changes: 7 additions & 10 deletions apps/desktop/src/app/shell/model-menu-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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 => {
Expand Down
76 changes: 52 additions & 24 deletions apps/desktop/src/components/model-visibility-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand All @@ -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),
Expand All @@ -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) =>
Expand All @@ -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"
Expand All @@ -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)

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>
)
})}
{!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"
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>
)
})
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
return (
<CheckboxPrimitive.Root
className={cn(
'peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
'group peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
data-slot="checkbox"
Expand All @@ -18,7 +18,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
className="flex items-center justify-center text-current"
data-slot="checkbox-indicator"
>
<Codicon name="check" size="0.875rem" />
<Codicon className="hidden group-data-[state=checked]:block" name="check" size="0.875rem" />
<Codicon className="hidden group-data-[state=indeterminate]:block" name="dash" size="0.875rem" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
Expand Down
103 changes: 103 additions & 0 deletions apps/desktop/src/store/model-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isProviderSentinel,
modelVisibilityKey,
resolveVisibleKeys,
setProviderVisibility,
toggleModelVisibility
} from './model-visibility'

Expand Down Expand Up @@ -224,3 +225,105 @@ describe('resolveVisibleKeys', () => {
expect([...resolveVisibleKeys(new Set(), providers)]).toEqual([])
})
})

describe('featured defaults', () => {
const featuredProvider = (slug: string, models: string[], featured_models: string[]): ModelOptionProvider => ({
featured_models,
models,
name: slug,
slug
})

it('defaults to the featured shortlist when a provider publishes one', () => {
const nous = featuredProvider(
'nous',
['anthropic/opus', 'anthropic/haiku', 'google/gemini', 'x-ai/grok'],
['anthropic/opus', 'google/gemini', 'x-ai/grok']
)

const visible = defaultVisibleKeys([nous])

// Featured are visible; the non-featured model is hidden by default.
expect(visible.has(modelVisibilityKey('nous', 'anthropic/opus'))).toBe(true)
expect(visible.has(modelVisibilityKey('nous', 'google/gemini'))).toBe(true)
expect(visible.has(modelVisibilityKey('nous', 'x-ai/grok'))).toBe(true)
expect(visible.has(modelVisibilityKey('nous', 'anthropic/haiku'))).toBe(false)
})

it('falls back to top-N when a provider ships no featured list', () => {
const plain = provider('ollama', ['qwen3:latest', 'llama3.2:latest'])

const visible = defaultVisibleKeys([plain])

// No featured_models → every model stays a default (top-N, N ≫ 2 here).
expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('ollama', 'llama3.2:latest'))).toBe(true)
})

it('ignores an empty featured list and falls back to top-N', () => {
const plain = featuredProvider('ollama', ['qwen3:latest', 'llama3.2:latest'], [])

const visible = defaultVisibleKeys([plain])

expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('ollama', 'llama3.2:latest'))).toBe(true)
})
})

describe('setProviderVisibility', () => {
const providers = [provider('openai', ['gpt-a', 'gpt-b']), provider('nous', ['hermes-x', 'hermes-y'])]

it('enabling a provider makes every one of its models visible', () => {
// Start from a hidden-all openai; flip it on.
const stored = new Set([emptyProviderSentinelKey('openai')])

const next = setProviderVisibility(stored, providers, 'openai', true)

const visible = effectiveVisibleKeys(next, providers)
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(true)
// Sentinel is cleared.
expect(next.has(emptyProviderSentinelKey('openai'))).toBe(false)
})

it('disabling a provider hides all its models and records the sentinel', () => {
const next = setProviderVisibility(null, providers, 'openai', false)

expect(next.has(emptyProviderSentinelKey('openai'))).toBe(true)
const visible = effectiveVisibleKeys(next, providers)
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(false)
expect(visible.has(modelVisibilityKey('openai', 'gpt-b'))).toBe(false)
})

it('leaves other providers untouched (their sentinels survive)', () => {
const stored = new Set([emptyProviderSentinelKey('nous')])

// Turn openai fully on; nous must stay hidden.
const next = setProviderVisibility(stored, providers, 'openai', true)

expect(next.has(emptyProviderSentinelKey('nous'))).toBe(true)
const visible = effectiveVisibleKeys(next, providers)
expect(visible.has(modelVisibilityKey('nous', 'hermes-x'))).toBe(false)
expect(visible.has(modelVisibilityKey('openai', 'gpt-a'))).toBe(true)
})

it('round-trips: enable then disable returns to a clean hidden-all', () => {
const enabled = setProviderVisibility(null, providers, 'openai', true)
const disabled = setProviderVisibility(enabled, providers, 'openai', false)

expect(disabled.has(emptyProviderSentinelKey('openai'))).toBe(true)
// No stray real keys left for the provider.
expect([...disabled].some(k => k.startsWith('openai::') && !isProviderSentinel(k))).toBe(false)
})

it('collapses model families to one key per family when enabling', () => {
// A base + its -fast sibling collapse to a single family row/key.
const ps = [provider('nous', ['model', 'model-fast'])]

const next = setProviderVisibility(null, ps, 'nous', true)

expect(next.has(modelVisibilityKey('nous', 'model'))).toBe(true)
// The -fast sibling is represented by its base family, not its own key.
expect(next.has(modelVisibilityKey('nous', 'model-fast'))).toBe(false)
})
})
Loading
Loading