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 f51c3b0e5672..7ae098d0f673 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -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() + }) +}) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 7091f29d1ac0..6dd816191ffa 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -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 ) : (