diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index ab3489c2..9bf9594c 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -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( + , + ) + + 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({ diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index f80e64ff..aca813e2 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -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(function ModelSelector({ value, onChange, + models, + ariaLabel, + appearance = 'toolbar', runtimeSelection: controlledRuntimeSelection, onRuntimeSelectionChange, runtimeKey, @@ -395,6 +406,7 @@ export const ModelSelector = forwardRef(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(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(function Mod return (
{/* 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(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. */} -
+
diff --git a/desktop/src/components/settings/AgentManager.test.tsx b/desktop/src/components/settings/AgentManager.test.tsx index 0eb9c079..d63e8576 100644 --- a/desktop/src/components/settings/AgentManager.test.tsx +++ b/desktop/src/components/settings/AgentManager.test.tsx @@ -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() 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() + 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() 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() 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') diff --git a/desktop/src/components/settings/AgentManager.tsx b/desktop/src/components/settings/AgentManager.tsx index dc36a439..00dc7600 100644 --- a/desktop/src/components/settings/AgentManager.tsx +++ b/desktop/src/components/settings/AgentManager.tsx @@ -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 = { 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({
- ({ value: model, label: model })), - { value: 'custom', label: t('settings.agents.form.customModel') }, - ]} /> @@ -724,15 +715,9 @@ function AgentFormModal({
- {modelChoice === 'custom' && ( - setCustomModel(event.target.value)} - /> - )} +

+ {t('settings.agents.form.modelProviderHint')} +

@@ -780,7 +765,6 @@ function AgentFormModal({ ...Object.keys(AGENT_COLORS).map((value) => ({ value, label: value, - icon: , })), ]} /> @@ -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(null) const [submitError, setSubmitError] = useState(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({
- ({ value: model, label: model })), - { value: 'custom', label: t('settings.agents.form.customModel') }, - ]} + defaultLabel={describeDefault(defaultModel)} /> @@ -1159,20 +1120,12 @@ function BuiltInAgentOverrideModal({
- {modelChoice === 'custom' && ( - setCustomModel(event.target.value)} - /> - )} -

{t('settings.agents.overrideHint')}

+

+ {t('settings.agents.form.modelProviderHint')} +

{t('settings.agents.overrideScopeHint')}

@@ -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() + 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 ( + + ) +} + function AgentSelect({ label, items, @@ -1325,33 +1350,23 @@ function AgentSelect({ 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 ( - - items={items} + + 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={( - - )} + disabled={disabled} + size="lg" /> ) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 407b0bab..374b3895 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1120,9 +1120,11 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.agents.form.systemPromptPlaceholder': 'Define this agent\'s role, boundaries, and expected output.', 'settings.agents.form.systemPromptRequired': 'Enter a system prompt.', 'settings.agents.form.model': 'Model', - 'settings.agents.form.customModel': 'Custom model ID', - 'settings.agents.form.customModelId': 'Custom model ID', - 'settings.agents.form.customModelRequired': 'Enter a custom model ID.', + 'settings.agents.form.modelDefaultDescription': 'Use the default shipped with this Claude Code build.', + 'settings.agents.form.modelInheritDescription': 'Use the same model as the main conversation.', + 'settings.agents.form.modelAliasDescription': 'CLI model alias mapped through the current provider.', + 'settings.agents.form.modelUnavailableDescription': 'Saved model ID; it is not listed by the current provider.', + 'settings.agents.form.modelProviderHint': 'Agent configuration stores a model ID, not a provider. It runs through the session\'s current provider.', 'settings.agents.form.effort': 'Reasoning effort', 'settings.agents.form.inherit': 'Inherit from parent', 'settings.agents.form.tools': 'Tools', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index d2ce5a85..d645b6f1 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1122,9 +1122,11 @@ export const jp: Record = { 'settings.agents.form.systemPromptPlaceholder': '役割、境界、期待する出力を定義します。', 'settings.agents.form.systemPromptRequired': 'システムプロンプトを入力してください。', 'settings.agents.form.model': 'モデル', - 'settings.agents.form.customModel': 'カスタムモデル ID', - 'settings.agents.form.customModelId': 'カスタムモデル ID', - 'settings.agents.form.customModelRequired': 'カスタムモデル ID を入力してください。', + 'settings.agents.form.modelDefaultDescription': 'この Claude Code ビルドに付属する既定モデルを使用します。', + 'settings.agents.form.modelInheritDescription': 'メイン会話と同じモデルを使用します。', + 'settings.agents.form.modelAliasDescription': '現在の Provider によって解決される CLI モデルエイリアスです。', + 'settings.agents.form.modelUnavailableDescription': '保存済みのモデル ID です。現在の Provider には表示されていません。', + 'settings.agents.form.modelProviderHint': 'Agent 設定には Provider ではなくモデル ID だけが保存され、実行時はセッションの現在の Provider を使用します。', 'settings.agents.form.effort': '推論の強度', 'settings.agents.form.inherit': '親から継承', 'settings.agents.form.tools': 'ツール', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 149e14e5..7442f3f7 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1122,9 +1122,11 @@ export const kr: Record = { 'settings.agents.form.systemPromptPlaceholder': '역할, 경계 및 예상 출력을 정의합니다.', 'settings.agents.form.systemPromptRequired': '시스템 프롬프트를 입력하세요.', 'settings.agents.form.model': '모델', - 'settings.agents.form.customModel': '사용자 지정 모델 ID', - 'settings.agents.form.customModelId': '사용자 지정 모델 ID', - 'settings.agents.form.customModelRequired': '사용자 지정 모델 ID를 입력하세요.', + 'settings.agents.form.modelDefaultDescription': '이 Claude Code 빌드에 포함된 기본 모델을 사용합니다.', + 'settings.agents.form.modelInheritDescription': '메인 대화와 같은 모델을 사용합니다.', + 'settings.agents.form.modelAliasDescription': '현재 Provider를 통해 매핑되는 CLI 모델 별칭입니다.', + 'settings.agents.form.modelUnavailableDescription': '저장된 모델 ID이며 현재 Provider 목록에는 없습니다.', + 'settings.agents.form.modelProviderHint': 'Agent 설정에는 Provider가 아닌 모델 ID만 저장되며 실행 시 세션의 현재 Provider를 사용합니다.', 'settings.agents.form.effort': '추론 강도', 'settings.agents.form.inherit': '상위에서 상속', 'settings.agents.form.tools': '도구', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 6782dd8e..ad14f6a0 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1121,9 +1121,11 @@ export const zh: Record = { 'settings.agents.form.systemPromptPlaceholder': '定義這個 Agent 的職責、邊界和預期輸出。', 'settings.agents.form.systemPromptRequired': '請輸入系統提示詞。', 'settings.agents.form.model': '模型', - 'settings.agents.form.customModel': '自訂模型 ID', - 'settings.agents.form.customModelId': '自訂模型 ID', - 'settings.agents.form.customModelRequired': '請輸入自訂模型 ID。', + 'settings.agents.form.modelDefaultDescription': '使用此 Claude Code 版本隨附的預設模型。', + 'settings.agents.form.modelInheritDescription': '與主工作階段使用相同模型。', + 'settings.agents.form.modelAliasDescription': 'CLI 模型別名,會對應到目前 Provider 的模型。', + 'settings.agents.form.modelUnavailableDescription': '已儲存的模型 ID;目前 Provider 未列出此模型。', + 'settings.agents.form.modelProviderHint': 'Agent 設定只儲存模型 ID,不綁定 Provider;執行時使用工作階段目前的 Provider。', 'settings.agents.form.effort': '思考強度', 'settings.agents.form.inherit': '繼承主 Agent', 'settings.agents.form.tools': '工具', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 856c3f02..aaea23c8 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1121,9 +1121,11 @@ export const zh: Record = { 'settings.agents.form.systemPromptPlaceholder': '定义这个 Agent 的职责、边界和预期输出。', 'settings.agents.form.systemPromptRequired': '请输入系统提示词。', 'settings.agents.form.model': '模型', - 'settings.agents.form.customModel': '自定义模型 ID', - 'settings.agents.form.customModelId': '自定义模型 ID', - 'settings.agents.form.customModelRequired': '请输入自定义模型 ID。', + 'settings.agents.form.modelDefaultDescription': '使用此 Claude Code 版本随附的默认模型。', + 'settings.agents.form.modelInheritDescription': '与主会话使用相同模型。', + 'settings.agents.form.modelAliasDescription': 'CLI 模型别名,会映射到当前 Provider 对应的模型。', + 'settings.agents.form.modelUnavailableDescription': '已保存的模型 ID;当前 Provider 未列出此模型。', + 'settings.agents.form.modelProviderHint': 'Agent 配置只保存模型 ID,不绑定 Provider;运行时使用会话当前的 Provider。', 'settings.agents.form.effort': '思考强度', 'settings.agents.form.inherit': '继承主 Agent', 'settings.agents.form.tools': '工具', diff --git a/docs/desktop/agents.md b/docs/desktop/agents.md index 353903c4..d4df6c00 100644 --- a/docs/desktop/agents.md +++ b/docs/desktop/agents.md @@ -59,7 +59,7 @@ order: 3 在列表里点内置 Agent 那一行的「调整模型」,或者进详情页点右上角的同名按钮。能改的只有两项: -- **模型** — 「内置默认」「继承主会话」「Haiku / Sonnet / Opus / Fable」,也可以填自定义模型 ID。 +- **模型** — 「内置默认」「继承主会话」、Haiku / Sonnet / Opus / Fable 别名,以及当前 Provider 已配置的模型。 - **思考强度** — 「内置默认」或低 / 中 / 高 / 极高 / 最大。 系统提示词、工具范围和颜色不能改,仍由 Claude Code 固定。 @@ -70,6 +70,8 @@ order: 3 覆盖写进 `~/.claude/settings.json` 的 `builtInAgentOverrides`,对所有项目生效。恢复默认时这条记录会被整个删掉,不会在配置文件里留下空壳。 +Agent 配置只保存模型 ID,不绑定 Provider。选择器会列出当前 Provider 的可用模型;如果以后切换 Provider,别名会按新 Provider 的映射解析,完整模型 ID 则需要新 Provider 也支持。 + 如果你自己建了一个同名的用户 Agent(比如手写一个 `name: Explore` 的 md 文件),它会完全盖住内置的那个,此时改内置的模型不会有任何效果——弹窗里会提示这一点。 ## 捏一个自己的 @@ -82,7 +84,7 @@ order: 3 2. **名称** — 1–64 位小写字母、数字、连字符或下划线,比如 `code-reviewer`。这是主 Agent 调用它时用的名字。 3. **描述** — 说明主 Agent 应该在什么场景下委派给它。**这一条最重要**:主 Agent 就是靠它决定要不要派这个 Agent,写含糊了就永远不会被叫到。 4. **系统提示词** — 定义这个 Agent 的职责、边界和预期输出。 -5. **模型** — 继承主 Agent,或指定 Haiku / Sonnet / Opus / Fable,也可以填自定义模型 ID。简单重复的活给 Haiku 更快更省。 +5. **模型** — 继承主 Agent,选择 Haiku / Sonnet / Opus / Fable 别名,或选择当前 Provider 已配置的模型。简单重复的活给 Haiku 更快更省。 6. **思考强度** — 继承,或单独指定低 / 中 / 高 / 极高 / 最大。模型不支持某档时会自动降级或忽略。 7. **工具** — 三选一:全部工具、不允许使用工具、自定义列表。选自定义时按读取与搜索 / 修改文件 / 执行命令 / 工作流分类勾选,下面还有一个自由输入框,用来填 MCP 工具名或者 `Bash(git:*)` 这样的权限规则。 8. **颜色** — 用来在界面上区分,可选。 diff --git a/docs/en/desktop/agents.md b/docs/en/desktop/agents.md index ec352790..8069604d 100644 --- a/docs/en/desktop/agents.md +++ b/docs/en/desktop/agents.md @@ -59,7 +59,7 @@ Built-in agents each pin a default model — `Explore` and `claude-code-guide` r Click **Adjust model** on the built-in agent's row, or the same button in the top-right of its detail page. Only two things are editable: -- **Model** — Built-in default, Inherit from parent, Haiku / Sonnet / Opus / Fable, or a custom model ID. +- **Model** — Built-in default, Inherit from parent, the Haiku / Sonnet / Opus / Fable aliases, or a model configured for the current provider. - **Reasoning effort** — Built-in default, or low / medium / high / xhigh / max. The system prompt, tool scope, and color stay fixed by Claude Code. @@ -70,6 +70,8 @@ The system prompt, tool scope, and color stay fixed by Claude Code. The override is written to `builtInAgentOverrides` in `~/.claude/settings.json` and applies to every project. Resetting removes the entry entirely rather than leaving an empty shell behind in your config. +Agent configuration stores a model ID, not a provider. The picker lists models from the current provider. If you switch providers later, aliases resolve through the new provider's mapping, while a full model ID must also be supported by the new provider. + If you create a user agent with the same name (a hand-written file with `name: Explore`, say), it shadows the built-in one completely — changing the built-in's model then has no effect, and the dialog says so. ## Writing your own @@ -82,7 +84,7 @@ Click **Create Agent** in the top right. The fields: 2. **Name** — 1–64 lowercase letters, digits, hyphens, or underscores, e.g. `code-reviewer`. This is what the main agent calls it by. 3. **Description** — when the main agent should delegate to it. **This is the field that matters most**: it's what the main agent reads to decide whether to call this agent at all. Write it vaguely and the agent will never be used. 4. **System prompt** — its responsibilities, boundaries, and expected output. -5. **Model** — inherit from the main agent, or pick Haiku / Sonnet / Opus / Fable, or enter a custom model ID. Simple repetitive work is faster and cheaper on Haiku. +5. **Model** — inherit from the main agent, choose a Haiku / Sonnet / Opus / Fable alias, or choose a model configured for the current provider. Simple repetitive work is faster and cheaper on Haiku. 6. **Effort** — inherit, or set low / medium / high / xhigh / max. Models that don't support a level downgrade or ignore it. 7. **Tools** — all tools, no tools, or a custom list. The custom picker groups built-in tools by read and search, modify files, execute commands, and workflow, with a free-text field below for MCP tool names or permission rules like `Bash(git:*)`. 8. **Color** — optional, purely for telling agents apart in the UI.