mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(desktop): reuse model picker for agent settings
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user