mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
+11
-11
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -17753,6 +17753,8 @@
|
||||
"options": "オプション",
|
||||
"sessionOptions": "セッションオプション",
|
||||
"chooseInAgentPicker": "Agent の選択画面で選ぶ…",
|
||||
"searchModels": "モデルを検索…",
|
||||
"noModelsMatch": "検索に一致するモデルはありません。",
|
||||
"toggleOption": "{{value0}}を切り替える",
|
||||
"pillAccessibleName": "{{value0}} {{value1}}",
|
||||
"sentNotConfirmed": "Agent に送信済み — 未確認",
|
||||
|
||||
@@ -17753,6 +17753,8 @@
|
||||
"options": "옵션",
|
||||
"sessionOptions": "세션 옵션",
|
||||
"chooseInAgentPicker": "에이전트 선택기에서 선택…",
|
||||
"searchModels": "모델 검색…",
|
||||
"noModelsMatch": "검색어와 일치하는 모델이 없습니다.",
|
||||
"toggleOption": "{{value0}} 전환",
|
||||
"pillAccessibleName": "{{value0}} {{value1}}",
|
||||
"sentNotConfirmed": "에이전트에 전송됨 — 확인되지 않음",
|
||||
|
||||
@@ -17718,6 +17718,8 @@
|
||||
"options": "选项",
|
||||
"sessionOptions": "会话选项",
|
||||
"chooseInAgentPicker": "在智能体选择器中选择…",
|
||||
"searchModels": "搜索模型…",
|
||||
"noModelsMatch": "没有符合搜索条件的模型。",
|
||||
"toggleOption": "切换{{value0}}",
|
||||
"pillAccessibleName": "{{value0}} {{value1}}",
|
||||
"sentNotConfirmed": "已发送给智能体 — 尚未确认",
|
||||
|
||||
Reference in New Issue
Block a user