fix(desktop): reuse model picker for agent settings

This commit is contained in:
程序员阿江(Relakkes)
2026-08-14 16:43:44 +08:00
parent 49cfd179fa
commit 9f5c1951a2
11 changed files with 265 additions and 142 deletions
@@ -363,6 +363,38 @@ describe('ModelSelector', () => {
expect(onChange).toHaveBeenCalledWith('beta')
})
it('uses caller-supplied models in the reusable field appearance', async () => {
const onChange = vi.fn()
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
})
const agentModels: ModelInfo[] = [
{ id: 'inherit', name: 'Inherit from parent', description: 'Use the parent model', context: '' },
{ id: 'provider-model', name: 'Provider Model', description: 'Configured model', context: '200k' },
]
render(
<ModelSelector
value="inherit"
onChange={onChange}
models={agentModels}
ariaLabel="Model"
appearance="field"
/>,
)
const trigger = screen.getByRole('button', { name: 'Model' })
expect(trigger.parentElement).toHaveClass('h-10', 'border-[var(--color-border)]')
fireEvent.click(trigger)
const dropdown = screen.getByTestId('model-selector-dropdown')
expect(within(dropdown).queryByRole('button', { name: /Alpha/ })).not.toBeInTheDocument()
fireEvent.click(within(dropdown).getByRole('button', { name: /Provider Model/ }))
expect(onChange).toHaveBeenCalledWith('provider-model')
})
it('routes uncontrolled model changes through settings actions', async () => {
const setModel = vi.fn(async () => {})
useSettingsStore.setState({
@@ -50,6 +50,14 @@ type ProviderChoice = {
type Props = {
value?: string
onChange?: (modelId: string) => void
/**
* Overrides the settings-store catalog for controlled pickers. This keeps
* the shared search, portal, and viewport positioning while allowing a
* feature to prepend semantic choices such as an inherited model.
*/
models?: ModelInfo[]
ariaLabel?: string
appearance?: 'toolbar' | 'field'
runtimeSelection?: RuntimeSelection
onRuntimeSelectionChange?: (selection: RuntimeSelection) => void
runtimeKey?: string
@@ -223,6 +231,9 @@ function modelMatchesSearch(model: ModelInfo, query: string): boolean {
export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function ModelSelector({
value,
onChange,
models,
ariaLabel,
appearance = 'toolbar',
runtimeSelection: controlledRuntimeSelection,
onRuntimeSelectionChange,
runtimeKey,
@@ -395,6 +406,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, grokOAuthStatus, openAIOAuthStatus],
)
const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase()
const selectableModels = isControlled && models ? models : availableModels
const filteredProviderChoices = useMemo(() => {
if (!normalizedSearchQuery) return providerChoices
@@ -408,13 +420,13 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
}, [normalizedSearchQuery, providerChoices])
const filteredAvailableModels = useMemo(
() => normalizedSearchQuery
? availableModels.filter(model => modelMatchesSearch(model, normalizedSearchQuery))
: availableModels,
[availableModels, normalizedSearchQuery],
? selectableModels.filter(model => modelMatchesSearch(model, normalizedSearchQuery))
: selectableModels,
[normalizedSearchQuery, selectableModels],
)
const selectedModel = isControlled
? availableModels.find((model) => model.id === value) || null
? selectableModels.find((model) => model.id === value) || null
: storeModel
const requestedRuntimeSelection = isRuntimeScoped
@@ -781,7 +793,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
return (
<div
data-testid="model-selector-shell"
className={`relative min-w-0 ${fluid ? 'flex-1' : 'shrink-0'}`}
className={`relative min-w-0 ${fluid || appearance === 'field' ? 'flex-1' : 'shrink-0'}`}
>
{/* No fill at rest: on the composer row the model name is type, not a
control chip — the handoff reserves filled pills for the permission
@@ -790,8 +802,16 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
bottom sheet, so both halves stretch to the same 44px touch target
`PermissionModeSelector` uses; `compact` alone would also shrink the
desktop composer, which narrows for the right panel, not for touch. */}
<div ref={ref} className={`flex min-w-0 items-stretch rounded-[var(--radius-md)] transition-colors hover:bg-[var(--color-surface-hover)] ${isMobileBrowser ? 'min-h-11' : ''} ${fluid ? 'w-full' : ''} ${disabled ? 'opacity-50' : ''}`}>
<div
ref={ref}
className={`flex min-w-0 items-stretch rounded-[var(--radius-md)] transition-colors ${
appearance === 'field'
? 'h-10 w-full border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)]'
: 'hover:bg-[var(--color-surface-hover)]'
} ${isMobileBrowser ? 'min-h-11' : ''} ${fluid ? 'w-full' : ''} ${disabled ? 'opacity-50' : ''}`}
>
<button
type="button"
onClick={() => {
if (disabled) return
if (open) {
@@ -801,17 +821,19 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
openSelector()
}}
disabled={disabled}
aria-label={buttonProviderLabel ? `${buttonModelLabel}, ${buttonProviderLabel}` : buttonModelLabel}
aria-label={ariaLabel ?? (buttonProviderLabel ? `${buttonModelLabel}, ${buttonProviderLabel}` : buttonModelLabel)}
title={buttonProviderLabel ? `${buttonProviderLabel} · ${buttonModelLabel}` : buttonModelLabel}
// `focus-visible:rounded-*` restores the other pair of corners while
// focused. The ring traces `border-radius`, so on the half-rounded
// halves of this segmented control it otherwise drew a box that was
// rounded down one side and square down the other.
className={`flex min-w-0 items-center gap-2 rounded-l-[var(--radius-md)] text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:rounded-[var(--radius-md)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed ${
compact ? `${fluid ? 'flex-1' : ''} max-w-[112px] py-1.5 pl-2.5 pr-1` : 'max-w-[220px] py-2 pl-2.5 pr-1'
className={`flex min-w-0 items-center gap-2 text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed ${
appearance === 'field'
? 'h-full w-full rounded-[var(--radius-md)] px-3 text-left'
: `rounded-l-[var(--radius-md)] focus-visible:rounded-[var(--radius-md)] ${compact ? `${fluid ? 'flex-1' : ''} max-w-[112px] py-1.5 pl-2.5 pr-1` : 'max-w-[220px] py-2 pl-2.5 pr-1'}`
}`}
>
<span className={`${compact ? 'text-xs' : 'text-[15px]'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
<span className={`${appearance === 'field' ? 'text-sm font-normal' : compact ? 'text-xs font-semibold' : 'text-[15px] font-semibold'} min-w-0 flex-1 truncate text-[var(--color-text-primary)]`}>
{buttonModelLabel}
</span>
{!canEditRuntimeEffort && !compact && buttonProviderLabel && (
@@ -819,7 +841,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
{buttonProviderLabel}
</span>
)}
<span className="material-symbols-outlined flex-shrink-0 text-[12px] text-[var(--color-text-tertiary)]">
<span className={`material-symbols-outlined flex-shrink-0 text-[var(--color-text-tertiary)] ${appearance === 'field' ? 'text-[16px]' : 'text-[12px]'}`}>
{needsProviderConfiguration ? 'arrow_forward' : 'expand_more'}
</span>
</button>
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -109,11 +109,15 @@ async function renderManager(response: AgentListResponse = EMPTY_RESPONSE) {
}
function chooseAgentSelect(label: string, option: string) {
// The trigger is a button; the entries inside the panel are listbox options.
// They used to be buttons, which is invalid inside a `role="listbox"` and
// left the dropdown without arrow-key navigation.
fireEvent.click(screen.getByRole('button', { name: label }))
fireEvent.click(screen.getByRole('option', { name: option }))
const select = screen.getByRole('combobox', { name: label })
const selectedOption = within(select).getByRole('option', { name: option }) as HTMLOptionElement
fireEvent.change(select, { target: { value: selectedOption.value } })
}
function chooseAgentModel(option: string | RegExp) {
fireEvent.click(screen.getByRole('button', { name: 'Model' }))
const picker = screen.getByTestId('model-selector-dropdown')
fireEvent.click(within(picker).getByRole('button', { name: option }))
}
describe('AgentManager', () => {
@@ -131,7 +135,23 @@ describe('AgentManager', () => {
},
})
recentProjectsMock.mockResolvedValue({ projects: [] })
useSettingsStore.setState({ locale: 'en' })
useSettingsStore.setState({
locale: 'en',
availableModels: [
{
id: 'provider/custom-model',
name: 'Provider Custom',
description: 'Current provider model',
context: '200k',
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek V4 Pro',
description: 'Current provider model',
context: '200k',
},
],
})
setProjectSession('/workspace/project')
useAgentStore.setState({
activeAgents: [],
@@ -165,17 +185,19 @@ describe('AgentManager', () => {
fireEvent.click(screen.getByRole('button', { name: 'Create Agent' }))
fireEvent.click(screen.getByRole('button', { name: 'Project' }))
expect(screen.getByRole('button', { name: 'Select a project...' })).toBeInTheDocument()
expect(document.querySelector('select')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Select a project...' }).tagName).toBe('BUTTON')
fireEvent.click(screen.getByRole('button', { name: 'Select a project...' }))
fireEvent.click(await screen.findByRole('button', { name: /Selected Project/ }))
expect(screen.getByText('Target project: /workspace/selected')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Model' }))
const modelMenuOption = screen.getByRole('option', { name: 'fable' })
const modelPicker = screen.getByTestId('model-selector-dropdown')
const modelMenuOption = within(modelPicker).getByRole('button', { name: /fable/i })
expect(modelMenuOption).toBeInTheDocument()
expect(modelMenuOption.parentElement).toHaveClass('bottom-full')
expect(screen.getByRole('dialog', { name: 'Create Agent' })).not.toContainElement(modelPicker)
expect(modelPicker).toHaveClass('fixed', 'z-[var(--z-dropdown)]')
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByRole('heading', { name: 'Create Agent' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'fable' })).not.toBeInTheDocument()
expect(screen.queryByTestId('model-selector-dropdown')).not.toBeInTheDocument()
expect(screen.getByLabelText('System prompt').parentElement).toHaveTextContent('System prompt*')
})
@@ -253,7 +275,7 @@ describe('AgentManager', () => {
expect(apiListMock).toHaveBeenNthCalledWith(3, '/workspace/b')
})
it('creates an underscore slug with custom model and effort, then selects the refreshed agent', async () => {
it('creates an underscore slug with a configured provider model and effort, then selects the refreshed agent', async () => {
const created = makeAgent({
source: 'projectSettings',
model: 'provider/custom-model',
@@ -278,8 +300,7 @@ describe('AgentManager', () => {
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'code_reviewer' } })
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Review code' } })
fireEvent.change(screen.getByLabelText('System prompt'), { target: { value: 'Review carefully.' } })
chooseAgentSelect('Model', 'Custom model ID')
fireEvent.change(screen.getByLabelText(/^Custom model ID/), { target: { value: 'provider/custom-model' } })
chooseAgentModel(/Provider Custom/)
chooseAgentSelect('Reasoning effort', 'xhigh')
chooseAgentSelect('Tools', 'Custom list')
fireEvent.click(screen.getByRole('checkbox', { name: /Read/ }))
@@ -445,7 +466,7 @@ describe('AgentManager', () => {
render(<AgentManager />)
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
chooseAgentSelect('Model', 'Inherit from parent')
chooseAgentModel(/Inherit from parent/)
chooseAgentSelect('Reasoning effort', 'Inherit from parent')
chooseAgentSelect('Tools', 'All tools')
chooseAgentSelect('Color', 'Default')
@@ -466,6 +487,27 @@ describe('AgentManager', () => {
expect(screen.getAllByText('Inherit').length).toBeGreaterThanOrEqual(2)
})
it('preserves a saved model ID that the current provider no longer lists', async () => {
const agent = makeAgent({ model: 'legacy/provider-model' })
apiListMock.mockResolvedValue({ activeAgents: [agent], allAgents: [agent] })
apiUpdateMock.mockResolvedValue({ agent })
useAgentStore.setState({ selectedAgent: agent, activeAgents: [agent], allAgents: [agent] })
render(<AgentManager />)
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.click(screen.getByRole('button', { name: 'Model' }))
const picker = screen.getByTestId('model-selector-dropdown')
expect(within(picker).getByRole('button', { name: /legacy\/provider-model.*not listed by the current provider/i })).toBeInTheDocument()
fireEvent.click(within(picker).getByRole('button', { name: /legacy\/provider-model/i }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(apiUpdateMock).toHaveBeenCalledWith(
'code_reviewer',
expect.objectContaining({ model: 'legacy/provider-model' }),
))
})
it('preserves an explicit empty tools list when editing only the description', async () => {
const agent = makeAgent({ tools: [] })
const updated = makeAgent({ tools: [], description: 'Updated review' })
@@ -480,7 +522,7 @@ describe('AgentManager', () => {
render(<AgentManager />)
await waitFor(() => expect(useAgentStore.getState().availableTools).toContain('Read'))
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByRole('button', { name: 'Tools' })).toHaveTextContent('No tools')
expect(screen.getByRole('combobox', { name: 'Tools' })).toHaveValue('none')
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Updated review' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
@@ -529,7 +571,7 @@ describe('AgentManager', () => {
render(<AgentManager />)
await waitFor(() => expect(apiListMock).toHaveBeenCalledTimes(1))
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByRole('button', { name: 'Reasoning effort' })).toHaveTextContent('7')
expect(screen.getByRole('combobox', { name: 'Reasoning effort' })).toHaveValue('7')
fireEvent.change(screen.getByLabelText(/^Description/), { target: { value: 'Updated review' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
@@ -776,10 +818,7 @@ describe('AgentManager', () => {
fireEvent.click(
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
)
chooseAgentSelect('Model', 'Custom model ID')
fireEvent.change(screen.getByLabelText(/^Custom model ID/), {
target: { value: 'deepseek-v4-pro' },
})
chooseAgentModel(/DeepSeek V4 Pro/)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(apiReloadMock).toHaveBeenCalledWith('running-session'))
@@ -819,9 +858,10 @@ describe('AgentManager', () => {
// inherit means "follow the main session" — collapsing them would make
// inherit unreachable, and the default label is read from the server.
fireEvent.click(screen.getByRole('button', { name: 'Model' }))
expect(screen.getByRole('option', { name: 'Built-in default (haiku)' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Inherit from parent' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('option', { name: 'Built-in default (haiku)' }))
const modelPicker = screen.getByTestId('model-selector-dropdown')
expect(within(modelPicker).getByRole('button', { name: /Built-in default \(haiku\)/ })).toBeInTheDocument()
expect(within(modelPicker).getByRole('button', { name: /Inherit from parent/ })).toBeInTheDocument()
fireEvent.click(within(modelPicker).getByRole('button', { name: /Built-in default \(haiku\)/ }))
chooseAgentSelect('Reasoning effort', 'high')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
@@ -844,7 +884,7 @@ describe('AgentManager', () => {
fireEvent.click(
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
)
chooseAgentSelect('Model', 'Inherit from parent')
chooseAgentModel(/Inherit from parent/)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(apiSetOverrideMock).toHaveBeenCalledWith('Explore', {
@@ -893,7 +933,7 @@ describe('AgentManager', () => {
)
expect(screen.getByRole('button', { name: 'Model' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reasoning effort' })).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'Reasoning effort' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
// Resetting would write to the user file, which cannot win over a policy.
expect(screen.queryByRole('button', { name: 'Reset to built-in default' })).toBeNull()
@@ -925,7 +965,7 @@ describe('AgentManager', () => {
fireEvent.click(
screen.getByRole('button', { name: 'Adjust the model and effort for Explore' }),
)
chooseAgentSelect('Model', 'sonnet')
chooseAgentModel(/^sonnet/i)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to save the override')
+100 -85
View File
@@ -7,7 +7,6 @@ import {
Bolt,
Braces,
Check,
ChevronDown,
CircleAlert,
Folder,
Hammer,
@@ -24,6 +23,7 @@ import {
} from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import type { ModelInfo } from '../../types/settings'
import type {
AgentDefinition,
AgentMutationInput,
@@ -32,6 +32,7 @@ import type {
} from '../../api/agents'
import { useAgentStore } from '../../stores/agentStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { getSessionBrowsablePath } from '../../lib/sessionWorkspace'
import { useUIStore } from '../../stores/uiStore'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
@@ -42,12 +43,13 @@ import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { LoadingState } from '@/components/ui/LoadingState'
import { DirectoryPicker } from '@/components/composite/DirectoryPicker'
import { Dropdown } from '@/components/ui/Dropdown'
import { IconButton } from '@/components/ui/IconButton'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/ui/Modal'
import { SearchField } from '@/components/ui/SearchField'
import { SelectField } from '@/components/ui/SelectField'
import { SettingsPageHeader } from '@/components/settings/SettingsSection'
import { ModelSelector } from '@/components/controls/ModelSelector'
const AGENT_COLORS: Record<string, string> = {
red: '#ef4444',
@@ -511,12 +513,7 @@ function AgentFormModal({
const [name, setName] = useState(agent?.agentType || '')
const [description, setDescription] = useState(agent?.description || '')
const [systemPrompt, setSystemPrompt] = useState(agent?.systemPrompt || '')
const [modelChoice, setModelChoice] = useState(
initialModel === 'inherit' || BUILT_IN_MODELS.includes(initialModel as typeof BUILT_IN_MODELS[number])
? initialModel
: 'custom',
)
const [customModel, setCustomModel] = useState(modelChoice === 'custom' ? initialModel : '')
const [modelChoice, setModelChoice] = useState(initialModel)
const initialEffort = agent?.effort === undefined ? 'inherit' : String(agent.effort)
const hasLegacyEffort = initialEffort !== 'inherit' && !EFFORTS.includes(initialEffort as typeof EFFORTS[number])
const [effort, setEffort] = useState(initialEffort)
@@ -548,7 +545,6 @@ function AgentFormModal({
if (!NAME_PATTERN.test(trimmedName)) nextErrors.name = t('settings.agents.form.nameError')
if (!description.trim()) nextErrors.description = t('settings.agents.form.descriptionRequired')
if (mode === 'create' && !systemPrompt.trim()) nextErrors.systemPrompt = t('settings.agents.form.systemPromptRequired')
if (modelChoice === 'custom' && !customModel.trim()) nextErrors.customModel = t('settings.agents.form.customModelRequired')
if (toolAccess === 'custom' && parsedTools.length === 0) nextErrors.tools = t('settings.agents.form.toolsCustomRequired')
if (scope === 'project' && !projectPath) nextErrors.scope = t('settings.agents.form.projectUnavailable')
setFieldErrors(nextErrors)
@@ -566,8 +562,8 @@ function AgentFormModal({
description: description.trim(),
systemPrompt: systemPrompt.trim(),
...(mode === 'edit'
? { model: modelChoice === 'inherit' ? null : modelChoice === 'custom' ? customModel.trim() : modelChoice }
: modelChoice === 'inherit' ? {} : { model: modelChoice === 'custom' ? customModel.trim() : modelChoice }),
? { model: modelChoice === 'inherit' ? null : modelChoice }
: modelChoice === 'inherit' ? {} : { model: modelChoice }),
...(mode === 'edit'
? { effort: effort === 'inherit' ? null : typeof agent?.effort === 'number' && effort === initialEffort ? agent.effort : effort }
: effort === 'inherit' ? {} : { effort }),
@@ -699,15 +695,10 @@ function AgentFormModal({
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t('settings.agents.form.model')}>
<AgentSelect
<AgentModelSelector
label={t('settings.agents.form.model')}
value={modelChoice}
onChange={setModelChoice}
items={[
{ value: 'inherit', label: t('settings.agents.form.inherit') },
...BUILT_IN_MODELS.map((model) => ({ value: model, label: model })),
{ value: 'custom', label: t('settings.agents.form.customModel') },
]}
/>
</Field>
<Field label={t('settings.agents.form.effort')}>
@@ -724,15 +715,9 @@ function AgentFormModal({
</Field>
</div>
{modelChoice === 'custom' && (
<Input
label={t('settings.agents.form.customModelId')}
required
value={customModel}
error={fieldErrors.customModel}
onChange={(event) => setCustomModel(event.target.value)}
/>
)}
<p className="-mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.agents.form.modelProviderHint')}
</p>
<Field label={t('settings.agents.form.tools')}>
<AgentSelect<ToolAccessMode>
@@ -780,7 +765,6 @@ function AgentFormModal({
...Object.keys(AGENT_COLORS).map((value) => ({
value,
label: value,
icon: <span className="h-3 w-3 rounded-full" style={{ backgroundColor: AGENT_COLORS[value] }} />,
})),
]}
/>
@@ -1018,19 +1002,11 @@ function BuiltInAgentOverrideModal({
const initialModel = agent.override?.model
const initialEffort = agent.override?.effort
const [modelChoice, setModelChoice] = useState(
initialModel === undefined
? DEFAULT_CHOICE
: initialModel === 'inherit' || BUILT_IN_MODELS.includes(initialModel as typeof BUILT_IN_MODELS[number])
? initialModel
: 'custom',
)
const [customModel, setCustomModel] = useState(
modelChoice === 'custom' ? (initialModel ?? '') : '',
initialModel ?? DEFAULT_CHOICE,
)
const [effort, setEffort] = useState(
initialEffort === undefined ? DEFAULT_CHOICE : String(initialEffort),
)
const [customModelError, setCustomModelError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const describeDefault = (value: string | number | undefined) =>
@@ -1039,11 +1015,6 @@ function BuiltInAgentOverrideModal({
: t('settings.agents.overrideDefault', { value: String(value) })
const handleSave = async () => {
if (modelChoice === 'custom' && !customModel.trim()) {
setCustomModelError(t('settings.agents.form.customModelRequired'))
return
}
setCustomModelError(null)
setSubmitError(null)
try {
await setAgentOverride(
@@ -1056,9 +1027,7 @@ function BuiltInAgentOverrideModal({
model:
modelChoice === DEFAULT_CHOICE
? null
: modelChoice === 'custom'
? customModel.trim()
: modelChoice,
: modelChoice,
effort: effort === DEFAULT_CHOICE ? null : effort,
},
sessionId,
@@ -1127,20 +1096,12 @@ function BuiltInAgentOverrideModal({
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t('settings.agents.form.model')}>
<AgentSelect
<AgentModelSelector
label={t('settings.agents.form.model')}
value={modelChoice}
onChange={setModelChoice}
disabled={isManaged}
items={[
// Two separate entries on purpose. For Explore the built-in
// default is haiku while "inherit" means follow the main
// session — collapsing them makes inherit unreachable.
{ value: DEFAULT_CHOICE, label: describeDefault(defaultModel) },
{ value: 'inherit', label: t('settings.agents.form.inherit') },
...BUILT_IN_MODELS.map((model) => ({ value: model, label: model })),
{ value: 'custom', label: t('settings.agents.form.customModel') },
]}
defaultLabel={describeDefault(defaultModel)}
/>
</Field>
<Field label={t('settings.agents.form.effort')}>
@@ -1159,20 +1120,12 @@ function BuiltInAgentOverrideModal({
</Field>
</div>
{modelChoice === 'custom' && (
<Input
label={t('settings.agents.form.customModelId')}
required
value={customModel}
error={customModelError ?? undefined}
disabled={isManaged}
onChange={(event) => setCustomModel(event.target.value)}
/>
)}
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.agents.overrideHint')}
</p>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.agents.form.modelProviderHint')}
</p>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.agents.overrideScopeHint')}
</p>
@@ -1317,6 +1270,78 @@ function getAgentSourceAccentClass(source: AgentSource) {
}
}
function AgentModelSelector({
label,
value,
onChange,
disabled,
defaultLabel,
}: {
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
defaultLabel?: string
}) {
const t = useTranslation()
const availableModels = useSettingsStore((state) => state.availableModels)
const models = useMemo(() => {
const choices: ModelInfo[] = []
const seen = new Set<string>()
const add = (model: ModelInfo) => {
if (seen.has(model.id)) return
seen.add(model.id)
choices.push(model)
}
if (defaultLabel) {
add({
id: DEFAULT_CHOICE,
name: defaultLabel,
description: t('settings.agents.form.modelDefaultDescription'),
context: '',
})
}
add({
id: 'inherit',
name: t('settings.agents.form.inherit'),
description: t('settings.agents.form.modelInheritDescription'),
context: '',
})
for (const alias of BUILT_IN_MODELS) {
add({
id: alias,
name: alias,
description: t('settings.agents.form.modelAliasDescription'),
context: '',
})
}
if (!seen.has(value) && value) {
add({
id: value,
name: value,
description: t('settings.agents.form.modelUnavailableDescription'),
context: '',
})
}
availableModels.forEach(add)
return choices
}, [availableModels, defaultLabel, t, value])
return (
<ModelSelector
value={value}
onChange={onChange}
models={models}
ariaLabel={label}
appearance="field"
disabled={disabled}
fluid
/>
)
}
function AgentSelect<T extends string>({
label,
items,
@@ -1325,33 +1350,23 @@ function AgentSelect<T extends string>({
disabled,
}: {
label: string
items: Array<{ value: T; label: string; icon?: ReactNode }>
items: Array<{ value: T; label: string }>
value: T
onChange: (value: T) => void
disabled?: boolean
}) {
const selected = items.find((item) => item.value === value) ?? items[0]
return (
<Dropdown<T>
items={items}
<SelectField<T>
label={label}
labelHidden
options={items.map(({ value: optionValue, label: optionLabel }) => ({
value: optionValue,
label: optionLabel,
}))}
value={value}
onChange={onChange}
width="100%"
maxHeight={280}
placement="top"
className="block w-full"
trigger={(
<button
type="button"
aria-label={label}
disabled={disabled}
className="flex h-10 w-full items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60"
>
{selected?.icon && <span className="shrink-0">{selected.icon}</span>}
<span className="min-w-0 flex-1 truncate">{selected?.label ?? value}</span>
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
)}
disabled={disabled}
size="lg"
/>
)
}