feat(native-chat): searchable model picker (#26011)

The model list was a flat menu that had to be scrolled to find an entry.
This commit is contained in:
Kelvin Amoaba
2026-10-06 23:28:22 -07:00
committed by GitHub
parent 0acf039b5d
commit eaceac0170
13 changed files with 388 additions and 50 deletions
@@ -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<void> =>
act(async () => new Promise((resolve) => setTimeout(resolve, 30)))
type ComboboxOptions = { defaultOpen?: boolean; readOnly?: boolean; readOnlyReason?: string | null }
type OnSelect = ReturnType<typeof vi.fn<(value: string) => void>>
function renderCombobox(props: ComboboxOptions = {}): OnSelect {
const onSelect = vi.fn<(value: string) => void>()
render(
<NativeChatModelCombobox
choices={CHOICES}
currentValue="opus"
defaultOpen={props.defaultOpen ?? true}
readOnly={props.readOnly ?? false}
readOnlyReason={props.readOnlyReason ?? null}
onSelect={onSelect}
renderTrigger={(onKeyDown) => (
<PopoverTrigger asChild>
<Button onKeyDown={onKeyDown}>Model</Button>
</PopoverTrigger>
)}
/>
)
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()
})
})
@@ -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<HTMLButtonElement>) => 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 (
<Popover open={open} onOpenChange={setOpen}>
{/* 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)
}
})}
<PopoverContent
align="start"
side="top"
collisionPadding={8}
className="w-64"
// Why: the popover loops Tab inside itself, which would trap focus in the search field.
onKeyDown={(event) => {
if (event.key === 'Tab') {
setOpen(false)
}
}}
>
<Command label={searchLabel} filter={modelChoiceFilter} defaultValue={currentValue}>
<CommandInput placeholder={searchLabel} />
{readOnly && readOnlyReason ? (
<div className="px-3 py-1.5 text-xs text-muted-foreground">{readOnlyReason}</div>
) : null}
<CommandList animateHeight>
<CommandEmpty>
{translate(
'components.native-chat.composer.noModelsMatch',
'No models match your search.'
)}
</CommandEmpty>
{choices.map((choice) => {
const label = nativeChatSessionChoiceLabel(choice)
const current = choice.value === currentValue
return (
<CommandItem
key={choice.value}
value={choice.value}
keywords={[label, choice.description ?? '']}
disabled={readOnly}
aria-current={current || undefined}
onSelect={() => {
setOpen(false)
onSelect(choice.value)
}}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{current ? <Check /> : null}
</span>
<NativeChatSessionOptionChoiceBody
label={label}
description={choice.description}
/>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,13 @@
export function NativeChatSessionOptionChoiceBody(props: {
label: string
description?: string
}): React.JSX.Element {
return (
<div className="min-w-0 py-0.5">
<div>{props.label}</div>
{props.description ? (
<div className="text-xs font-normal text-muted-foreground">{props.description}</div>
) : null}
</div>
)
}
@@ -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<void> =>
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()
})
})
@@ -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 }) => (
<div data-testid="dropdown-root" data-open={open ? 'true' : 'false'}>
{children}
</div>
),
PopoverTrigger: ({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) => (
<div data-disabled={disabled || undefined}>{children}</div>
),
PopoverContent: ({
children,
side,
collisionPadding
}: {
children: React.ReactNode
side?: string
collisionPadding?: number
}) => (
<div
data-testid="session-option-menu"
data-side={side}
data-collision-padding={collisionPadding}
>
{children}
</div>
)
}))
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(
<NativeChatSessionOptionPickers
surface={{ ...surface, setOption }}
snapshot={[model()]}
isWorking={false}
/>
)
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(
<NativeChatSessionOptionPickers
@@ -13,6 +13,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { sortNativeChatSessionOptions } from '../../../../shared/native-chat-session-option-snapshot'
@@ -34,6 +35,8 @@ import type { NativeChatOptionPickerRequest } from './native-chat-composer-types
import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
import { NativeChatModelCombobox } from './NativeChatModelCombobox'
import { NativeChatSessionOptionChoiceBody } from './NativeChatSessionOptionChoiceBody'
export type NativeChatSessionOptionPickersProps = {
surface: SessionOptionsSurface | null
@@ -63,11 +66,14 @@ function PickerTooltipContent(props: {
}
function PickerTrigger(props: {
/** The trigger of whichever surface the pill opens. */
Trigger: typeof DropdownMenuTrigger | typeof PopoverTrigger
label: string
tooltipLabel: string
disabled: boolean
disabledReason?: string | null
dispatched: boolean
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>
}): 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 (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild disabled={props.disabled}>
<props.Trigger asChild disabled={props.disabled}>
<Button
type="button"
variant="ghost"
size="xs"
aria-label={accessibleName}
className="max-w-48"
onKeyDown={props.onKeyDown}
>
<span className="truncate">{props.label}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
</props.Trigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<PickerTooltipContent
@@ -105,17 +112,6 @@ function PickerTrigger(props: {
)
}
function ChoiceBody(props: { label: string; description?: string }): React.JSX.Element {
return (
<div className="min-w-0 py-0.5">
<div>{props.label}</div>
{props.description ? (
<div className="text-xs font-normal text-muted-foreground">{props.description}</div>
) : null}
</div>
)
}
function DescriptorMenuRows(props: {
descriptor: SessionOptionDescriptor
pending: boolean
@@ -179,7 +175,7 @@ function DescriptorMenuRows(props: {
value={choice.value}
disabled={!descriptor.settable || pending}
>
<ChoiceBody
<NativeChatSessionOptionChoiceBody
label={nativeChatSessionChoiceLabel(choice)}
description={choice.description}
/>
@@ -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 (
<div className="flex min-w-0 items-center gap-0.5 text-chat-foreground-faint">
<DropdownMenu
key={`model:${requestedModelSequence ?? 'idle'}`}
// Read only when a request remounts the menu: one made while pending is spent shut.
defaultOpen={requestedModelSequence !== null && !modelChoicesPending}
>
<PickerTrigger
label={nativeChatModelPillLabel(model)}
tooltipLabel={modelTooltip}
disabled={isWorking || pendingId !== null || modelChoicesPending}
disabledReason={modelReason}
dispatched={sessionOptionDispatchUnconfirmed(model)}
{model.kind.type === 'select' && !model.action ? (
<NativeChatModelCombobox
key={modelPickerKey}
defaultOpen={modelPickerDefaultOpen}
choices={model.kind.choices}
currentValue={model.kind.currentValue}
readOnly={!model.settable || pendingId !== null}
readOnlyReason={modelReason}
onSelect={(value) => setOption(model, value)}
renderTrigger={(onKeyDown) => (
<PickerTrigger Trigger={PopoverTrigger} {...modelTrigger} onKeyDown={onKeyDown} />
)}
/>
<DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-64">
{modelReason && !model.settable ? (
<DropdownMenuLabel className="font-normal">{modelReason}</DropdownMenuLabel>
) : null}
<DescriptorMenuRows
descriptor={model}
pending={pendingId !== null}
setValue={(value) => setOption(model, value)}
invokeAction={() => invokeAction(model)}
/>
</DropdownMenuContent>
</DropdownMenu>
) : (
<DropdownMenu key={modelPickerKey} defaultOpen={modelPickerDefaultOpen}>
<PickerTrigger Trigger={DropdownMenuTrigger} {...modelTrigger} />
<DropdownMenuContent align="start" side="top" collisionPadding={8} className="w-64">
{modelReason && !model.settable ? (
<DropdownMenuLabel className="font-normal">{modelReason}</DropdownMenuLabel>
) : null}
<DescriptorMenuRows
descriptor={model}
pending={pendingId !== null}
setValue={(value) => setOption(model, value)}
invokeAction={() => invokeAction(model)}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
{options.length > 0 ? (
<DropdownMenu
key={`options:${requestedOptionsSequence ?? 'idle'}`}
defaultOpen={requestedOptionsSequence !== null}
>
<PickerTrigger
Trigger={DropdownMenuTrigger}
label={nativeChatOptionsPillLabel(options)}
tooltipLabel={optionsTooltip}
disabled={isWorking || pendingId !== null}
+7 -1
View File
@@ -132,9 +132,13 @@ function CommandInput({
function CommandList({
className,
animateHeight = false,
ref,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
}: React.ComponentProps<typeof CommandPrimitive.List> & {
/** Glide to the filtered height (cmdk publishes it) instead of jumping. */
animateHeight?: boolean
}) {
const internalRef = React.useRef<HTMLDivElement>(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}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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é",
+2
View File
@@ -17753,6 +17753,8 @@
"options": "オプション",
"sessionOptions": "セッションオプション",
"chooseInAgentPicker": "Agent の選択画面で選ぶ…",
"searchModels": "モデルを検索…",
"noModelsMatch": "検索に一致するモデルはありません。",
"toggleOption": "{{value0}}を切り替える",
"pillAccessibleName": "{{value0}} {{value1}}",
"sentNotConfirmed": "Agent に送信済み — 未確認",
+2
View File
@@ -17753,6 +17753,8 @@
"options": "옵션",
"sessionOptions": "세션 옵션",
"chooseInAgentPicker": "에이전트 선택기에서 선택…",
"searchModels": "모델 검색…",
"noModelsMatch": "검색어와 일치하는 모델이 없습니다.",
"toggleOption": "{{value0}} 전환",
"pillAccessibleName": "{{value0}} {{value1}}",
"sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음",
+2
View File
@@ -17718,6 +17718,8 @@
"options": "选项",
"sessionOptions": "会话选项",
"chooseInAgentPicker": "在智能体选择器中选择…",
"searchModels": "搜索模型…",
"noModelsMatch": "没有符合搜索条件的模型。",
"toggleOption": "切换{{value0}}",
"pillAccessibleName": "{{value0}} {{value1}}",
"sentNotConfirmed": "已发送给智能体 — 尚未确认",