diff --git a/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx b/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx new file mode 100644 index 00000000000..3521acd99f9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +import { Button } from '@/components/ui/button' +import { PopoverTrigger } from '@/components/ui/popover' +import { NativeChatModelCombobox } from './NativeChatModelCombobox' + +const CHOICES = [ + { value: 'gpt-5.5', label: 'GPT-5.5', description: 'Frontier reasoning' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'opus', label: 'Opus 4.8' } +] + +const settle = (): Promise => + act(async () => new Promise((resolve) => setTimeout(resolve, 30))) + +type ComboboxOptions = { defaultOpen?: boolean; readOnly?: boolean; readOnlyReason?: string | null } +type OnSelect = ReturnType void>> + +function renderCombobox(props: ComboboxOptions = {}): OnSelect { + const onSelect = vi.fn<(value: string) => void>() + render( + ( + + + + )} + /> + ) + return onSelect +} + +/** Renders the picker already open and returns its search field. */ +async function openCombobox( + props: ComboboxOptions = {} +): Promise<{ search: HTMLElement; onSelect: OnSelect }> { + const onSelect = renderCombobox(props) + await settle() + return { search: screen.getByRole('combobox'), onSelect } +} + +const listedModels = (): (string | null)[] => + screen.queryAllByRole('option').map((option) => option.getAttribute('data-value')) + +const highlightedModel = (): string | null => + screen.queryByRole('option', { selected: true })?.getAttribute('data-value') ?? null + +afterEach(() => cleanup()) + +describe('NativeChatModelCombobox', () => { + it('opens with the search focused and the current model highlighted and marked', async () => { + const { search } = await openCombobox() + expect(document.activeElement).toBe(search) + expect(screen.getByRole('combobox', { name: 'Search models…' })).toBe(search) + expect(listedModels()).toEqual(['gpt-5.5', 'gpt-5.2-codex', 'opus']) + expect(highlightedModel()).toBe('opus') + expect(screen.getByRole('option', { current: true }).getAttribute('data-value')).toBe('opus') + }) + + it('focuses the search when the pill is clicked', async () => { + renderCombobox({ defaultOpen: false }) + fireEvent.click(screen.getByRole('button', { name: 'Model' })) + await settle() + expect(document.activeElement).toBe(screen.getByRole('combobox')) + }) + + it('opens with the search focused when ArrowDown is pressed on the pill', async () => { + renderCombobox({ defaultOpen: false }) + const pill = screen.getByRole('button', { name: 'Model' }) + pill.focus() + fireEvent.keyDown(pill, { key: 'ArrowDown' }) + await settle() + expect(document.activeElement).toBe(screen.getByRole('combobox')) + }) + + it('filters by every typed term across label, id and description, in list order', async () => { + const { search } = await openCombobox() + + fireEvent.change(search, { target: { value: 'gpt' } }) + expect(listedModels()).toEqual(['gpt-5.5', 'gpt-5.2-codex']) + expect(highlightedModel()).toBe('gpt-5.5') + + fireEvent.change(search, { target: { value: 'GPT codex' } }) + expect(listedModels()).toEqual(['gpt-5.2-codex']) + + fireEvent.change(search, { target: { value: 'frontier' } }) + expect(listedModels()).toEqual(['gpt-5.5']) + + fireEvent.change(search, { target: { value: 'gemini' } }) + expect(listedModels()).toEqual([]) + expect(screen.getByText('No models match your search.')).not.toBeNull() + }) + + it('moves the highlight with the arrows while focus stays in the search', async () => { + const { search, onSelect } = await openCombobox() + fireEvent.change(search, { target: { value: 'gpt' } }) + fireEvent.keyDown(search, { key: 'ArrowDown' }) + expect(highlightedModel()).toBe('gpt-5.2-codex') + expect(document.activeElement).toBe(search) + + fireEvent.keyDown(search, { key: 'Enter' }) + await settle() + expect(onSelect).toHaveBeenCalledExactlyOnceWith('gpt-5.2-codex') + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('closes on Tab instead of trapping focus in the search', async () => { + const { search } = await openCombobox() + fireEvent.keyDown(search, { key: 'Tab' }) + await settle() + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('picks a clicked row and closes', async () => { + const { onSelect } = await openCombobox() + fireEvent.click(screen.getByRole('option', { name: /GPT-5.2 Codex/ })) + await settle() + expect(onSelect).toHaveBeenCalledExactlyOnceWith('gpt-5.2-codex') + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('lists but does not pick models it cannot set, and says why', async () => { + const { search, onSelect } = await openCombobox({ + readOnly: true, + readOnlyReason: 'Set when the session starts.' + }) + expect(screen.getByText('Set when the session starts.')).not.toBeNull() + fireEvent.change(search, { target: { value: 'codex' } }) + fireEvent.keyDown(search, { key: 'Enter' }) + fireEvent.click(screen.getByRole('option', { name: /GPT-5.2 Codex/ })) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx new file mode 100644 index 00000000000..a6d1842cbf4 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatModelCombobox.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react' +import { Check } from 'lucide-react' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Popover, PopoverContent } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import type { SessionOptionSelectChoice } from '../../../../shared/native-chat-session-options' +import { nativeChatSessionChoiceLabel } from './native-chat-session-option-labels' +import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody' + +type NativeChatModelComboboxProps = { + choices: readonly SessionOptionSelectChoice[] + currentValue: string | undefined + /** Read on mount only: the owner remounts to open on request. */ + defaultOpen: boolean + /** Choices stay listed and searchable, but cannot be picked. */ + readOnly: boolean + readOnlyReason: string | null + onSelect: (value: string) => void + /** Renders the `PopoverTrigger` pill, which must take the key handler. */ + renderTrigger: (onKeyDown: React.KeyboardEventHandler) => React.ReactNode +} + +// Why not cmdk's scorer: it matches scattered letters and reorders the host's list. +function modelChoiceFilter(value: string, search: string, keywords?: string[]): number { + const haystack = [value, ...(keywords ?? [])].join(' ').toLowerCase() + const terms = search.toLowerCase().split(/\s+/).filter(Boolean) + return terms.every((term) => haystack.includes(term)) ? 1 : 0 +} + +export function NativeChatModelCombobox({ + choices, + currentValue, + defaultOpen, + readOnly, + readOnlyReason, + onSelect, + renderTrigger +}: NativeChatModelComboboxProps): React.JSX.Element { + const [open, setOpen] = useState(defaultOpen) + const searchLabel = translate('components.native-chat.composer.searchModels', 'Search models…') + return ( + + {/* Why: a popover trigger opens on Enter and Space only; the menu it replaced also took ArrowDown. */} + {renderTrigger((event) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setOpen(true) + } + })} + { + if (event.key === 'Tab') { + setOpen(false) + } + }} + > + + + {readOnly && readOnlyReason ? ( +
{readOnlyReason}
+ ) : null} + + + {translate( + 'components.native-chat.composer.noModelsMatch', + 'No models match your search.' + )} + + {choices.map((choice) => { + const label = nativeChatSessionChoiceLabel(choice) + const current = choice.value === currentValue + return ( + { + setOpen(false) + onSelect(choice.value) + }} + > + + {current ? : null} + + + + ) + })} + +
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx new file mode 100644 index 00000000000..f5b100ef5a8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionChoiceBody.tsx @@ -0,0 +1,13 @@ +export function NativeChatSessionOptionChoiceBody(props: { + label: string + description?: string +}): React.JSX.Element { + return ( +
+
{props.label}
+ {props.description ? ( +
{props.description}
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx index 90abdc3ef25..a6221046e46 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -// Against the real menu: a `/model` request opens the menu once, and a period while the host still -// lists models neither opens it later nor reopens one the user closed. +// Against the real picker: a `/model` request opens it once with the search focused, and a period +// while the host still lists models neither opens it later nor reopens one the user closed. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -72,38 +72,38 @@ const settle = (): Promise => afterEach(() => cleanup()) -describe('model menu requests while the host lists models', () => { - it('spends a request made while pending without opening the menu once the list lands', async () => { +describe('model picker requests while the host lists models', () => { + it('spends a request made while pending without opening the picker once the list lands', async () => { const request = { id: 'model', sequence: 1 } const { rerender } = render(view(true, request)) const composer = screen.getByTestId('composer') composer.focus() await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() rerender(view(false, request)) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() expect(document.activeElement).toBe(composer) // A new request after the list landed still opens it. rerender(view(false, { id: 'model', sequence: 2 })) await settle() - expect(screen.queryByRole('menu')).not.toBeNull() + expect(document.activeElement).toBe(screen.getByRole('combobox')) }) - it('does not reopen a menu the user closed when a pending period ends', async () => { + it('does not reopen a picker the user closed when a pending period ends', async () => { const request = { id: 'model', sequence: 1 } const { rerender } = render(view(false, request)) await settle() - fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' }) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() rerender(view(true, request)) await settle() rerender(view(false, request)) await settle() - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index 0c82460833f..770547021d3 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -158,6 +158,35 @@ vi.mock('@/components/ui/dropdown-menu', () => { } }) +// Same test ids as the dropdown mock: the model pill is a popover, the options pill a menu. +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children, open }: { children: React.ReactNode; open?: boolean }) => ( +
+ {children} +
+ ), + PopoverTrigger: ({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) => ( +
{children}
+ ), + PopoverContent: ({ + children, + side, + collisionPadding + }: { + children: React.ReactNode + side?: string + collisionPadding?: number + }) => ( +
+ {children} +
+ ) +})) + import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' const surface = { @@ -268,6 +297,19 @@ describe('NativeChatSessionOptionPickers', () => { ) }) + it('sends a picked model to the session surface', async () => { + const setOption = vi.fn().mockResolvedValue({ snapshot: [] }) + render( + + ) + screen.getByRole('option', { name: 'Sonnet 5' }).click() + await waitFor(() => expect(setOption).toHaveBeenCalledExactlyOnceWith('model', 'sonnet')) + }) + it('prefers collision-aware upward placement for model and option menus', () => { render( }): React.JSX.Element { // Why: value-only visible text must still include the category in the // accessible name (WCAG 2.5.3 Label in Name / voice control). @@ -81,18 +87,19 @@ function PickerTrigger(props: { return ( - + - + -
{props.label}
- {props.description ? ( -
{props.description}
- ) : null} - - ) -} - function DescriptorMenuRows(props: { descriptor: SessionOptionDescriptor pending: boolean @@ -179,7 +175,7 @@ function DescriptorMenuRows(props: { value={choice.value} disabled={!descriptor.settable || pending} > - @@ -241,7 +237,16 @@ function NativeChatSessionOptionPickersInner({ } const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) - const modelTooltip = translate('components.native-chat.composer.model', 'Model') + const modelPickerKey = `model:${requestedModelSequence ?? 'idle'}` + // Read only when a request remounts the picker: one made while pending is spent shut. + const modelPickerDefaultOpen = requestedModelSequence !== null && !modelChoicesPending + const modelTrigger = { + label: nativeChatModelPillLabel(model), + tooltipLabel: translate('components.native-chat.composer.model', 'Model'), + disabled: isWorking || pendingId !== null || modelChoicesPending, + disabledReason: modelReason, + dispatched: sessionOptionDispatchUnconfirmed(model) + } const optionsTooltip = nativeChatOptionsPillTitle(options) const optionsReason = options.length > 0 && options.every((descriptor) => !descriptor.settable) @@ -250,36 +255,42 @@ function NativeChatSessionOptionPickersInner({ return (
- - setOption(model, value)} + renderTrigger={(onKeyDown) => ( + + )} /> - - {modelReason && !model.settable ? ( - {modelReason} - ) : null} - setOption(model, value)} - invokeAction={() => invokeAction(model)} - /> - - + ) : ( + + + + {modelReason && !model.settable ? ( + {modelReason} + ) : null} + setOption(model, value)} + invokeAction={() => invokeAction(model)} + /> + + + )} {options.length > 0 ? ( ) { +}: React.ComponentProps & { + /** Glide to the filtered height (cmdk publishes it) instead of jumping. */ + animateHeight?: boolean +}) { const internalRef = React.useRef(null) // Why: Radix Dialog applies react-remove-scroll which calls preventDefault() @@ -177,6 +181,8 @@ function CommandList({ data-slot="command-list" className={cn( 'max-h-[min(400px,60vh)] overflow-y-auto overflow-x-hidden scrollbar-sleek scroll-pb-4 scroll-pt-4', + animateHeight && + 'h-[min(var(--cmdk-list-height),400px,60vh)] transition-[height] duration-150 ease-out motion-reduce:transition-none', className )} {...props} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index be90a908af1..e1801efb165 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17997,6 +17997,8 @@ "options": "Options", "sessionOptions": "Session options", "chooseInAgentPicker": "Choose in agent picker…", + "searchModels": "Search models…", + "noModelsMatch": "No models match your search.", "toggleOption": "Toggle {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Sent to the agent — not confirmed", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ec35b2afad7..97b830ccb6f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17722,6 +17722,8 @@ "options": "Opciones", "sessionOptions": "Opciones de sesión", "chooseInAgentPicker": "Elegir en el selector del agente…", + "searchModels": "Buscar modelos…", + "noModelsMatch": "Ningún modelo coincide con la búsqueda.", "toggleOption": "Alternar {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Enviado al agente — sin confirmar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f82a2c893ef..eeae1e13ba7 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17834,6 +17834,8 @@ "options": "Options", "sessionOptions": "Options de session", "chooseInAgentPicker": "Choisir dans le sélecteur d'agents…", + "searchModels": "Rechercher des modèles…", + "noModelsMatch": "Aucun modèle ne correspond à votre recherche.", "toggleOption": "Basculer {{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Envoyé à l'agent — non confirmé", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7d375de9b31..2108344b64e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17753,6 +17753,8 @@ "options": "オプション", "sessionOptions": "セッションオプション", "chooseInAgentPicker": "Agent の選択画面で選ぶ…", + "searchModels": "モデルを検索…", + "noModelsMatch": "検索に一致するモデルはありません。", "toggleOption": "{{value0}}を切り替える", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "Agent に送信済み — 未確認", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e00f6fe5b7a..34b20fe540f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17753,6 +17753,8 @@ "options": "옵션", "sessionOptions": "세션 옵션", "chooseInAgentPicker": "에이전트 선택기에서 선택…", + "searchModels": "모델 검색…", + "noModelsMatch": "검색어와 일치하는 모델이 없습니다.", "toggleOption": "{{value0}} 전환", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c3ad03d8049..a1e865ee12f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17718,6 +17718,8 @@ "options": "选项", "sessionOptions": "会话选项", "chooseInAgentPicker": "在智能体选择器中选择…", + "searchModels": "搜索模型…", + "noModelsMatch": "没有符合搜索条件的模型。", "toggleOption": "切换{{value0}}", "pillAccessibleName": "{{value0}} {{value1}}", "sentNotConfirmed": "已发送给智能体 — 尚未确认",