fix(desktop): honor pre-session model pick + restore global reasoning/speed defaults (#47447)

* fix(desktop): keep the pre-session model pick selected in the picker

The composer picker derived its "current" row from `model.options ?? store`,
so model.options always won. Pre-session that query returns the PROFILE
DEFAULT, not the sticky composer pick — so selecting a model before a session
exists left the checkmark (and the picker's "current" line) on the default,
making the pick look ignored even though the pill updated.

Add `currentPickerSelection()`: with a live session the gateway's model.options
is authoritative; pre-session the sticky `$currentModel`/`$currentProvider`
wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog.

* feat(desktop): global reasoning/speed defaults in Settings → Model

The composer picker is now sticky-UI/per-session only and never writes the
profile default (#46959), but Settings → Model had no reasoning/speed control
and `agent.reasoning_effort` wasn't in the curated config surface at all
(`service_tier` was buried in Advanced) — so there was nowhere to set the
profile default that crons/subagents/messaging resolve from.

Add capability-gated Reasoning (effort) + Fast controls beside the main model,
gated by the applied model's reported capabilities (reasoning defaults on, fast
off when unreported — same as the composer). They read/write `agent.reasoning_effort`
and `agent.service_tier` by round-tripping the config record, matching the
gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast).

* refactor(desktop): don't open the reasoning select from its row label

A <label> wrapping the Select forwarded text clicks to the trigger, opening
the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so
clicking its text toggles the switch (expected for a checkbox-like control).
This commit is contained in:
brooklyn! 2026-06-16 16:22:09 -05:00 committed by GitHub
parent d1ecebcbfd
commit b7f0c9cd52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 197 additions and 10 deletions

View file

@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
}))
vi.mock('@/store/onboarding', () => ({
@ -35,7 +39,13 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
]
@ -47,6 +57,8 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()

View file

@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfigRecord,
getRecommendedDefaultModel,
saveHermesConfig,
setEnvVar,
setModelAssignment
} from '@/hermes'
@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
getAuxiliaryModels(),
getHermesConfigRecord()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
setConfig(cfg)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && (
<div className="mt-2">

View file

@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
@ -84,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
}
})
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!activeSessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useI18n } from '@/i18n'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
@ -66,8 +67,13 @@ export function ModelPickerDialog({
})
const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!sessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -538,6 +538,10 @@ export const en: Translations = {
provider: 'Provider',
model: 'Model',
applying: 'Applying...',
defaultsLabel: 'Defaults',
reasoning: 'Reasoning',
reasoningOff: 'Off',
defaultsFailed: 'Failed to save model defaults',
auxiliaryTitle: 'Auxiliary models',
resetAllToMain: 'Reset all to main',
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',

View file

@ -430,6 +430,10 @@ export interface Translations {
provider: string
model: string
applying: string
defaultsLabel: string
reasoning: string
reasoningOff: string
defaultsFailed: string
auxiliaryTitle: string
resetAllToMain: string
auxiliaryDesc: string

View file

@ -733,6 +733,10 @@ export const zh: Translations = {
provider: '提供方',
model: '模型',
applying: '应用中...',
defaultsLabel: '默认值',
reasoning: '推理',
reasoningOff: '关闭',
defaultsFailed: '保存模型默认值失败',
auxiliaryTitle: '辅助模型',
resetAllToMain: '全部重置为主模型',
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => {
it('formats display names consistently', () => {
@ -35,4 +35,25 @@ describe('model-status-label', () => {
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
})
describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' }
it('prefers the sticky composer pick over the profile default pre-session', () => {
expect(currentPickerSelection(false, store, options)).toEqual(store)
})
it('lets the live session model.options win when a session exists', () => {
expect(currentPickerSelection(true, store, options)).toEqual(options)
})
it('falls back to options when the store is empty', () => {
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
})
it('falls back to the store while options are still loading', () => {
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
})
})
})

View file

@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
return REASONING_LABELS[key] ?? effort
}
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
* "current", so the sticky composer pick wins over the profile default the
* global options query returns else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()