{
+ // Claim arrows and Enter from Radix so DOM focus stays in the input
+ // and Enter commits the highlighted row without a DownArrow first.
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ event.stopPropagation()
+ stepKb(event.key === 'ArrowDown' ? 1 : -1)
+ } else if (event.key === 'Enter') {
+ event.preventDefault()
+ event.stopPropagation()
+ commitKbRow()
+ }
+ }}
+ onValueChange={value => {
+ setSearch(value)
+ setKbOverride(null)
+ }}
+ placeholder={copy.search}
+ value={search}
+ />
+
+
+
+ {loading ? (
+
+ {Array.from({ length: 4 }, (_, index) => (
+ event.preventDefault()}
+ >
+
+
+ ))}
+
+ ) : error ? (
+
+ {error}
+
+ ) : groups.length === 0 && moaPresets.length === 0 ? (
+
+ {copy.noModels}
+
+ ) : (
+
+ {groups.map(group => {
+ const slug = group.provider.slug
+
+ // Collapsed when the user stored it (and not while searching, which
+ // spans every model regardless of collapse state).
+ const collapsed = collapsedProviders.includes(slug) && !search
+
+ return (
+
+ {
+ event.preventDefault()
+ toggleCollapsedProvider(slug)
+ }}
+ textValue=""
+ >
+
+
+
+
+
+ {!collapsed &&
+ group.families.map(family => {
+ // The active id may be the base or its -fast sibling; either
+ // way this one family row represents both.
+ const activeId =
+ group.provider.slug === current.provider &&
+ (current.model === family.id || current.model === family.fastId)
+ ? current.model
+ : null
+
+ const isCurrent = activeId !== null
+ const name = modelDisplayParts(family.id).name
+ const caps = group.provider.capabilities?.[family.id]
+
+ // Effective settings for this row: the live choice when it's
+ // the active model, otherwise its remembered preset. Row
+ // label AND submenu read from these so they never disagree.
+ const preset = controller.presetFor(group.provider.slug, family.id)
+ const effEffort = isCurrent ? current.effort : (preset.effort ?? '')
+ const effFast = isCurrent ? current.fast : (preset.fast ?? false)
+
+ const fastControl: FastControl = resolveFastControl(
+ activeId ?? family.id,
+ group.provider.models ?? [],
+ caps?.fast ?? false,
+ effFast
+ )
+
+ const meta = [
+ fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
+ (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null
+ ]
+ .filter(Boolean)
+ .join(' ')
+
+ // Clicking the row commits the model and closes; the edit
+ // submenu (reasoning/fast) is reached by HOVER, so you can
+ // tweak those without the click dismissing everything.
+ const activate = () => {
+ if (!isCurrent) {
+ void selectFamily(family, group.provider)
+ }
+
+ closeMenu()
+ }
+
+ return (
+
+ {
+ if (event.key === 'Enter' || event.key === ' ') {
+ activate()
+ }
+ }}
+ {...kbRowProps(`${group.provider.slug}:${family.id}`)}
+ >
+
+
+ {meta ? {meta} : null}
+
+ {isCurrent ? (
+
+ ) : null}
+
+ controller.select(nextModel, group.provider.slug)}
+ onSetOptions={patch =>
+ controller.setOptions(patch, {
+ isActive: isCurrent,
+ model: family.id,
+ provider: group.provider.slug
+ })
+ }
+ provider={group.provider.slug}
+ reasoning={caps?.reasoning ?? true}
+ />
+
+ )
+ })}
+
+ )
+ })}
+
+ )}
+
+ {shownMoaPresets.length > 0 ? (
+
+
+ MoA presets
+ {shownMoaPresets.map(preset => {
+ const isCurrentMoa = current.provider === 'moa' && current.model === preset
+
+ return (
+ {
+ event.preventDefault()
+ void selectMoaPreset(preset)
+ }}
+ {...kbRowProps(`moa:${preset}`)}
+ >
+
+ MoA:
+
+ {isCurrentMoa ? : null}
+
+ )
+ })}
+
+ ) : null}
+
+ {/* Curation belongs to the catalog, not to one host: wherever you can
+ pick a model you can say which models you want, and the shortlist is
+ the same everywhere because it's one stored preference. It shares the
+ host footer's group rather than opening a second one, so a host that
+ contributes rows (the composer's Refresh Models) keeps the single
+ trailing block it has always rendered. */}
+
+ {footer}
+ setModelVisibilityOpen(true)}
+ >
+
+ {copy.editModels}
+
+ >
+ )
+}
+
+/** Re-exported so callers building a footer row match the catalog's rows. */
+export { dropdownMenuRow }
+
+// Collapsed we show the user's chosen models (or the curated default); typing
+// spans every available model so anything is reachable past the cut. A search
+// is itself a narrowing action, so we do NOT cap per-provider matches.
+function groupModels(
+ providers: ModelOptionProvider[],
+ search: string,
+ current: { model: string; provider: string },
+ visible: Set | null
+): ProviderGroup[] {
+ const q = normalize(search)
+ const groups: ProviderGroup[] = []
+
+ for (const provider of providers) {
+ const allFamilies = collapseModelFamilies(provider.models ?? [])
+
+ if (allFamilies.length === 0) {
+ continue
+ }
+
+ const matches = (family: ModelFamily) =>
+ `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
+ .toLowerCase()
+ .includes(q)
+
+ let shown: Set
+
+ if (q) {
+ // Search spans every family, regardless of visibility.
+ shown = new Set(allFamilies.filter(matches).map(family => family.id))
+ } else if (visible) {
+ // User has customized which models show — honor their selection exactly.
+ shown = new Set(
+ allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
+ )
+ } else {
+ shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
+ }
+
+ // Always include the active model — but keep every row in the provider's
+ // stable curated order, so selecting a model can't shuffle the list. While
+ // SEARCHING the pin is skipped: a query means "show me matches".
+ const activeId =
+ !q && provider.slug === current.provider && current.model
+ ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
+ : undefined
+
+ const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
+
+ if (families.length > 0) {
+ groups.push({ families, provider })
+ }
+ }
+
+ // Stable, logical group order: alphabetical by provider name. (The backend
+ // floats the current provider first, which would reshuffle on every switch.)
+ groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
+
+ return groups
+}
+
+// Small hooks kept at the bottom so the component reads top-down.
+function useStoreCollapsed(): string[] {
+ return useStore($collapsedProviders)
+}
+
+function useDefaultEffort(): string {
+ return useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
+}
diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx
index 4e552303b572b..52513c4d00805 100644
--- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx
+++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
@@ -7,26 +7,9 @@ import {
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
-import type * as HermesApi from '@/hermes'
-import { $modelPresets, getModelPreset } from '@/store/model-presets'
-import {
- $activeSessionId,
- $currentFastMode,
- $currentReasoningEffort,
- getCurrentModelSource,
- setCurrentFastMode,
- setCurrentModelSource,
- setCurrentReasoningEffort
-} from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
-vi.mock('@/hermes', async importOriginal => {
- const actual = await importOriginal()
-
- return { ...actual, setApiRequestProfile: vi.fn() }
-})
-
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@@ -34,35 +17,36 @@ beforeAll(() => {
Element.prototype.releasePointerCapture = vi.fn()
})
-beforeEach(() => {
- $modelPresets.set({})
- $activeSessionId.set(null)
- setCurrentFastMode(false)
- setCurrentModelSource('')
- setCurrentReasoningEffort('')
-})
-
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
-function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise }) {
+function renderSubmenu(opts: {
+ defaultEffort?: string
+ effort?: string
+ fastControl: FastControl
+ isActive?: boolean
+ onSelectModel?: (model: string) => void
+ onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
+ reasoning: boolean
+}) {
return render(
edit
@@ -70,43 +54,72 @@ function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; req
)
}
-// Regression: editing the active row before a live session exists must stay
-// preset-only — the gateway's config.set falls back to global config when no
-// session matches, so it must not be called. (Caught in the second review.)
-describe('ModelEditSubmenu no-session guard', () => {
- it('param fast: records explicit off in the draft but skips the gateway without a session', () => {
- const requestGateway = vi.fn().mockResolvedValue({})
- setCurrentFastMode(true)
- renderSubmenu({ fastControl: { kind: 'param', on: true }, reasoning: false, requestGateway })
+// The submenu is PURE: it reports edits and never writes to a session, a
+// preset store, or the gateway. That's the invariant that lets the same
+// component drive a live chat session AND a detached per-task override — if it
+// ever writes directly again, picking an effort for a kanban card would reach
+// over and change the user's live chat.
+describe('ModelEditSubmenu reports edits without performing them', () => {
+ it('param fast: reports the toggle', () => {
+ const onSetOptions = vi.fn()
+ renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
fireEvent.click(screen.getByRole('switch'))
- expect(getModelPreset('p1', 'm1').fast).toBe(false)
- expect($currentFastMode.get()).toBe(false)
- expect(getCurrentModelSource()).toBe('manual')
- expect(requestGateway).not.toHaveBeenCalled()
+ expect(onSetOptions).toHaveBeenCalledWith({ fast: false })
})
- it('reasoning: records the preset but skips the gateway without a session', () => {
- const requestGateway = vi.fn().mockResolvedValue({})
- renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
+ it('thinking: toggling off reports the none level', () => {
+ const onSetOptions = vi.fn()
+ renderSubmenu({ fastControl: { kind: 'none' }, onSetOptions, reasoning: true })
- // Thinking starts on (medium); toggling it off routes through patchReasoning.
+ // Thinking starts on (medium); toggling it off reports 'none'.
fireEvent.click(screen.getByRole('switch'))
- expect(getModelPreset('p1', 'm1').effort).toBe('none')
- expect($currentReasoningEffort.get()).toBe('none')
- expect(getCurrentModelSource()).toBe('manual')
- expect(requestGateway).not.toHaveBeenCalled()
+ expect(onSetOptions).toHaveBeenCalledWith({ effort: 'none' })
})
- it('param fast: pushes to the gateway once a session is active', async () => {
- const requestGateway = vi.fn().mockResolvedValue({})
- $activeSessionId.set('sess1')
- renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
+ it('thinking: toggling back on restores the row level, not the hardcoded default', () => {
+ const onSetOptions = vi.fn()
+ renderSubmenu({ defaultEffort: 'high', effort: 'none', fastControl: { kind: 'none' }, onSetOptions, reasoning: true })
fireEvent.click(screen.getByRole('switch'))
- expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
+ expect(onSetOptions).toHaveBeenCalledWith({ effort: 'high' })
+ })
+
+ it('variant fast: swaps the model only when the row is active', () => {
+ const onSelectModel = vi.fn()
+ const onSetOptions = vi.fn()
+
+ renderSubmenu({
+ fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false },
+ isActive: false,
+ onSelectModel,
+ onSetOptions,
+ reasoning: false
+ })
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ // Inactive rows stay preference-only — no model switch.
+ expect(onSetOptions).toHaveBeenCalledWith({ fast: true })
+ expect(onSelectModel).not.toHaveBeenCalled()
+ })
+
+ it('variant fast: active row swaps to the -fast sibling', () => {
+ const onSelectModel = vi.fn()
+ const onSetOptions = vi.fn()
+
+ renderSubmenu({
+ fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false },
+ onSelectModel,
+ onSetOptions,
+ reasoning: false
+ })
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(onSelectModel).toHaveBeenCalledWith('m1-fast')
})
})
diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx
index 94c241bdf04e6..1d5b5dc598dd7 100644
--- a/apps/desktop/src/app/shell/model-edit-submenu.tsx
+++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx
@@ -1,6 +1,3 @@
-import { useStore } from '@nanostores/react'
-
-import { useSessionView } from '@/app/chat/session-view'
import {
DropdownMenuItem,
DropdownMenuLabel,
@@ -13,21 +10,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
-import {
- DEFAULT_REASONING_EFFORT,
- isThinkingEnabled,
- REASONING_EFFORTS,
- resolveReasoningEffort
-} from '@/lib/reasoning-effort'
-import { setModelPreset } from '@/store/model-presets'
-import { notifyError } from '@/store/notifications'
-import {
- $defaultReasoningEffort,
- markComposerSelectionManual,
- setCurrentFastMode,
- setCurrentReasoningEffort
-} from '@/store/session'
-import { sessionTileDelegate } from '@/store/session-states'
+import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort'
// Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned
// by the Thinking toggle, not the radio.
@@ -76,6 +59,9 @@ export function resolveFastControl(
}
interface ModelEditSubmenuProps {
+ /** The profile's configured default effort — what an unset row inherits.
+ * Passed in (not read from a store) so this submenu stays pure. */
+ defaultEffort: string
/** This row's effective reasoning effort (live for the active model, else its
* preset) — the submenu shows and edits from this, never the raw session. */
effort: string
@@ -83,15 +69,19 @@ interface ModelEditSubmenuProps {
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
- /** This row's model id — edits persist as its global preset. */
+ /** This row's model id. */
model: string
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
- onSelectModel: (model: string) => Promise | void
- /** This row's provider slug — edits persist as its global preset. */
+ onSelectModel: (model: string) => Promise | void
+ /** Report an option change. This submenu is PURE: it never writes to a
+ * session, a preset store, or the gateway itself — the owning surface's
+ * controller decides what an edit means. That's what lets the same submenu
+ * drive a live chat session and a detached per-task override. */
+ onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
+ /** This row's provider slug. */
provider: string
/** Whether this model supports reasoning effort. */
reasoning: boolean
- requestGateway: (method: string, params?: Record) => Promise
}
export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
@@ -108,72 +98,26 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
}
function ModelEditSubmenuBody({
+ defaultEffort,
effort,
fastControl,
isActive,
- model,
onSelectModel,
- provider,
- reasoning,
- requestGateway
+ onSetOptions,
+ reasoning
}: ModelEditSubmenuProps) {
const { t } = useI18n()
const copy = t.shell.modelOptions
- const view = useSessionView()
- const activeSessionId = useStore(view.$runtimeId)
- const touchesPrimary = view.kind === 'primary'
- const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const effortValue = resolveReasoningEffort(effort, defaultEffort)
const thinkingOn = isThinkingEnabled(effort, defaultEffort)
- // Editing always records the model's global preset (keyed by provider::model,
- // not per-surface — a tile edit re-applies to that model everywhere); the
- // active model also gets it pushed onto its OWN session (primary → globals,
- // tile → its slice). Non-active edits stay preset-only — no model switch.
- const patchReasoning = async (next: string) => {
- setModelPreset(provider, model, { effort: next })
-
- if (!isActive) {
- return
- }
-
- if (touchesPrimary) {
- markComposerSelectionManual()
- setCurrentReasoningEffort(next)
- } else if (activeSessionId) {
- sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next }))
- }
-
- // Preset-only without a session: `isActive` holds for the global/default
- // row pre-session, and the gateway's `config.set` falls back to global
- // config when none matches — so don't reach it (preset + optimistic store
- // are the whole effect). Same guard in applyModelPreset / setFast.
- if (!activeSessionId) {
- return
- }
-
- try {
- await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
- } catch (err) {
- if (touchesPrimary) {
- setCurrentReasoningEffort(effort)
- } else if (activeSessionId) {
- sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: effort }))
- }
-
- setModelPreset(provider, model, { effort })
- notifyError(err, copy.updateFailed)
- }
- }
-
const setFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
- // Fast is a separate model id. Record the choice on the base model's
- // preset (selectFamily picks the `-fast` sibling later when set), and
- // only swap models now if this is the active row — inactive edits must
- // stay preset-only, same as the param path below.
- setModelPreset(provider, fastControl.baseId, { fast: enabled })
+ // Fast is a separate model id. Report the choice so the controller can
+ // record it against the base model, and only swap models now if this is
+ // the active row — inactive edits stay preference-only.
+ onSetOptions({ fast: enabled })
if (isActive) {
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
@@ -183,41 +127,7 @@ function ModelEditSubmenuBody({
}
if (fastControl.kind === 'param') {
- setModelPreset(provider, model, { fast: enabled })
-
- if (!isActive) {
- return
- }
-
- if (touchesPrimary) {
- markComposerSelectionManual()
- setCurrentFastMode(enabled)
- } else if (activeSessionId) {
- sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled }))
- }
-
- // Preset-only without a session (see patchReasoning).
- if (!activeSessionId) {
- return
- }
- void (async () => {
- try {
- await requestGateway('config.set', {
- key: 'fast',
- session_id: activeSessionId,
- value: enabled ? 'fast' : 'normal'
- })
- } catch (err) {
- if (touchesPrimary) {
- setCurrentFastMode(!enabled)
- } else if (activeSessionId) {
- sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled }))
- }
-
- setModelPreset(provider, model, { fast: !enabled })
- notifyError(err, copy.fastFailed)
- }
- })()
+ onSetOptions({ fast: enabled })
}
}
@@ -235,7 +145,7 @@ function ModelEditSubmenuBody({
void patchReasoning(checked ? effortValue || defaultEffort : 'none')}
+ onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })}
size="xs"
/>
@@ -250,7 +160,7 @@ function ModelEditSubmenuBody({
<>
{copy.effort}
- void patchReasoning(value)} value={effortValue}>
+ onSetOptions({ effort: value })} value={effortValue}>
{REASONING_EFFORTS.map(value => (
void>(() => {})
+export { ModelMenuCloseContext } from './model-catalog-menu'
export interface ModelSelection {
model: string
@@ -62,16 +42,15 @@ interface ModelMenuPanelProps {
requestGateway: (method: string, params?: Record) => Promise
}
-interface ProviderGroup {
- families: ModelFamily[]
- provider: ModelOptionProvider
-}
-
+/**
+ * The composer's model menu: `ModelCatalogMenu` (the shared renderer) plus the
+ * controller that gives a selection its meaning HERE — write through to this
+ * surface's session, remember the pick as a global preset, keep the optimistic
+ * stores honest, and roll back on a failed gateway write.
+ */
export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', requestGateway }: ModelMenuPanelProps) {
const { t } = useI18n()
const copy = t.shell.modelMenu
- const closeMenu = useContext(ModelMenuCloseContext)
- const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
// Bind to THIS surface's SessionView (primary or tile) so each pane's menu
@@ -85,13 +64,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
const modelPresets = useStore($modelPresets)
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const visibleModels = useStore($visibleModels)
- const collapsedProviders = useStore($collapsedProviders)
+ const touchesPrimary = view.kind === 'primary'
+ // Subscribe to the SAME query the menu runs (identical key ⇒ React Query
+ // dedupes, no second fetch). It must be a live subscription, not a cache
+ // peek: with no model in the session store yet, currentPickerSelection falls
+ // back to the catalog's reported current, and a non-reactive read would
+ // never repaint that fallback once the catalog resolved.
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, activeSessionId),
- // Gateway-first even with no session yet: a connected (possibly remote)
- // gateway owns the model catalog, including virtual providers like `moa`
- // that the local REST fallback can't know about (#53817).
queryFn: (): Promise => requestModelOptions({ gateway, sessionId: activeSessionId })
})
@@ -100,42 +81,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
modelOptions.data
)
- const loading = modelOptions.isPending && !modelOptions.data
-
- const error = modelOptions.error
- ? modelOptions.error instanceof Error
- ? modelOptions.error.message
- : String(modelOptions.error)
- : null
-
- const providers = modelOptions.data?.providers
-
- // The catalog carries MoA presets as a virtual `moa` provider row. Render
- // them in their dedicated section below and keep the row out of the main
- // provider groups so presets don't show up twice.
- const moaPresets = useMemo(
- () => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [],
- [providers]
- )
-
- const pickerProviders = useMemo(
- () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [],
- [providers]
- )
-
- const effectiveVisibleModels = useMemo(
- () => effectiveVisibleKeys(visibleModels, pickerProviders),
- [visibleModels, pickerProviders]
- )
-
- // The composer picker never persists the profile default. With a session it
- // scopes the switch to that session; with none it's UI state shipped on the
- // next session.create (see selectModel). The default lives in Settings → Model.
- // Always stamp sessionId from this surface so a tile switch never hits the
- // primary (busy) session by accident.
- const switchTo = (model: string, provider: string) =>
- onSelectModel({ model, provider, sessionId: activeSessionId || null })
-
// Explicit "Refresh Models": re-fetch the catalog with refresh:true so the
// backend busts its 1h provider-model disk cache and re-pulls each provider's
// live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing
@@ -162,448 +107,138 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
}
}
- // Selecting a model row restores that model's remembered preset onto the
- // session (effort/fast), gated by capability. Unset → Hermes defaults.
- const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
- const caps = provider.capabilities?.[family.id]
- const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
+ // Push a reasoning change onto the session that owns it, with rollback.
+ const patchReasoning = async (next: string, previous: string, provider: string, model: string) => {
+ if (touchesPrimary) {
+ markComposerSelectionManual()
+ setCurrentReasoningEffort(next)
+ } else if (activeSessionId) {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next }))
+ }
- // Variant-fast models (no speed param) express "fast" as a separate `-fast`
- // id, so honor the saved preset by selecting that sibling. Param-fast is
- // applied via applyModelPreset below instead.
- const variantFast = !(caps?.fast ?? false) && !!family.fastId
- const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
-
- if ((await switchTo(targetId, provider.slug)) === false) {
+ // Preset-only without a session: the gateway's `config.set` falls back to
+ // global config when none matches — so don't reach it (preset + optimistic
+ // store are the whole effect).
+ if (!activeSessionId) {
return
}
- await applyModelPreset(
- {
- effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined,
- fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
- },
- {
+ try {
+ await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
+ } catch (err) {
+ if (touchesPrimary) {
+ setCurrentReasoningEffort(previous)
+ } else {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: previous }))
+ }
+
+ setModelPreset(provider, model, { effort: previous })
+ notifyError(err, t.shell.modelOptions.updateFailed)
+ }
+ }
+
+ const patchFast = async (enabled: boolean, provider: string, model: string) => {
+ if (touchesPrimary) {
+ markComposerSelectionManual()
+ setCurrentFastMode(enabled)
+ } else if (activeSessionId) {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled }))
+ }
+
+ if (!activeSessionId) {
+ return
+ }
+
+ try {
+ await requestGateway('config.set', {
+ key: 'fast',
+ session_id: activeSessionId,
+ value: enabled ? 'fast' : 'normal'
+ })
+ } catch (err) {
+ if (touchesPrimary) {
+ setCurrentFastMode(!enabled)
+ } else {
+ sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled }))
+ }
+
+ setModelPreset(provider, model, { fast: !enabled })
+ notifyError(err, t.shell.modelOptions.fastFailed)
+ }
+ }
+
+ const controller: ModelMenuController = {
+ // Selecting a model row restores that model's remembered preset onto the
+ // session (effort/fast). applyModelPreset owns the batched gateway write.
+ applyPreset: (preset, row) => {
+ setModelPreset(row.provider, row.model, preset)
+
+ void applyModelPreset(preset, {
failMessage: t.shell.modelOptions.updateFailed,
- primary: view.kind === 'primary',
+ primary: touchesPrimary,
request: requestGateway,
sessionId: activeSessionId
+ })
+ },
+
+ current: {
+ effort: currentReasoningEffort,
+ fast: currentFastMode,
+ model: optionsModel,
+ provider: optionsProvider
+ },
+
+ presetFor: (provider, model) => modelPresets[modelPresetKey(provider, model)] ?? {},
+
+ // The composer picker never persists the profile default. With a session it
+ // scopes the switch to that session; with none it's UI state shipped on the
+ // next session.create. Always stamp sessionId from this surface so a tile
+ // switch never hits the primary (busy) session by accident.
+ select: (model, provider) => onSelectModel({ model, provider, sessionId: activeSessionId || null }),
+
+ setOptions: (patch, row) => {
+ // Editing always records the model's global preset (keyed by
+ // provider::model, not per-surface — a tile edit re-applies to that model
+ // everywhere); the active model also gets it pushed onto its OWN session.
+ // Non-active edits stay preset-only — no model switch, no session write.
+ if (patch.effort !== undefined || patch.fast !== undefined) {
+ setModelPreset(row.provider, row.model, patch)
}
- )
- }
- // Selecting a MoA preset switches the session to it PERSISTENTLY, using the
- // same path real provider selections use (onSelectModel → config.set with
- // --session for live sessions → the gateway's persistent switch_model).
- // Previously this dispatched the one-shot `/moa` command, which ran a single
- // turn through MoA and then silently reverted to the prior model (#54670) —
- // the dropdown presented presets like persistent selections but they weren't.
- // No session gate: like regular model rows, a pre-session pick is UI state
- // shipped on the next session.create.
- const selectMoaPreset = async (preset: string) => {
- if ((await switchTo(preset, 'moa')) === false) {
- return
- }
+ if (!row.isActive) {
+ return
+ }
- closeMenu()
- }
+ if (patch.effort !== undefined) {
+ void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model)
+ }
- const groups = useMemo(
- () =>
- groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
- [pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels]
- )
-
- const q = normalize(search)
-
- // Presets are searchable rows like everything else — an unfiltered preset
- // sitting under zero model matches would otherwise become the "first match"
- // Enter commits.
- const shownMoaPresets = useMemo(
- () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets),
- [moaPresets, q]
- )
-
- // ── Keyboard selection (cmdk semantics on a Radix menu) ───────────────────
- // One flat list mirroring EXACTLY what's rendered (collapse, filter, presets),
- // so the selection can never sit on a hidden row. The selected index is
- // derived — current model with no query (Enter = close), first match while
- // typing — with an arrow-key override that resets on every keystroke. Focus
- // stays in the search input throughout: ⌘⇧M → type → ↑/↓ → Enter.
- type KbRow =
- | { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider }
- | { key: string; kind: 'moa'; preset: string }
-
- const kbRows = useMemo(
- () => [
- ...groups.flatMap(group =>
- collapsedProviders.includes(group.provider.slug) && !search
- ? []
- : group.families.map((family): KbRow => ({
- family,
- key: `${group.provider.slug}:${family.id}`,
- kind: 'family',
- provider: group.provider
- }))
- ),
- ...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset }))
- ],
- [groups, collapsedProviders, search, shownMoaPresets]
- )
-
- const [kbOverride, setKbOverride] = useState(null)
- // A parked cursor is not a cursor in use: until the mouse actually moves,
- // hover can't take rows out from under the keyboard (rows re-flow beneath it
- // as the filter narrows). One real movement hands hover back.
- const pointerQuiet = usePointerQuiet()
-
- const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}`
-
- const autoIndex = q
- ? kbRows.length > 0
- ? 0
- : -1
- : kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === optionsModel))
-
- const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex
- const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null
-
- const stepKb = (delta: -1 | 1) => {
- if (kbRows.length === 0) {
- return
- }
-
- const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0
-
- setKbOverride((from + delta + kbRows.length) % kbRows.length)
- }
-
- const commitKbRow = () => {
- const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined
-
- if (!row) {
- return
- }
-
- if (row.kind === 'moa') {
- void selectMoaPreset(row.preset)
-
- return
- }
-
- if (row.key !== currentKey && row.family.fastId !== optionsModel) {
- void selectFamily(row.family, row.provider)
- }
-
- closeMenu()
- }
-
- // Keep the selected row in view while arrowing through the scrollable list.
- const listRef = useRef(null)
-
- useEffect(() => {
- listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
- }, [kbActiveKey])
-
- // The keyboard-selected row, styled + tagged for scrollIntoView. Pointer
- // suppression is NOT here — it belongs on the containers (below), so one
- // class covers every row inside them.
- const kbRowProps = (key: string) => {
- const active = kbActiveKey === key
-
- return {
- className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'),
- ...(active ? { 'data-kb-active': '' } : {})
+ if (patch.fast !== undefined) {
+ void patchFast(patch.fast, row.provider, row.model)
+ }
}
}
- // Rows are hover-selectable, so they go inert with the pointer (usePointerQuiet).
- const quietRows = pointerQuiet && 'pointer-events-none'
-
return (
- <>
- {
- // Claim arrows and Enter from Radix so DOM focus stays in the input
- // and Enter commits the highlighted row without a DownArrow first
- // (VS Code's checked-or-first pattern).
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ {
event.preventDefault()
- event.stopPropagation()
- stepKb(event.key === 'ArrowDown' ? 1 : -1)
- } else if (event.key === 'Enter') {
- event.preventDefault()
- event.stopPropagation()
- commitKbRow()
- }
- }}
- onValueChange={value => {
- setSearch(value)
- setKbOverride(null)
- }}
- placeholder={copy.search}
- value={search}
- />
-
-
-
- {loading ? (
-
- {Array.from({ length: 4 }, (_, index) => (
- event.preventDefault()}
- >
-
-
- ))}
-
- ) : error ? (
-
- {error}
+ void refreshModels()
+ }}
+ >
+
+ {copy.refreshModels}
- ) : groups.length === 0 && moaPresets.length === 0 ? (
-
- {copy.noModels}
-
- ) : (
-
- {groups.map(group => {
- const slug = group.provider.slug
-
- // Collapsed when the user stored it (and not while searching, which
- // spans every model regardless of collapse state).
- const collapsed = collapsedProviders.includes(slug) && !search
-
- return (
-
- {
- event.preventDefault()
- toggleCollapsedProvider(slug)
- }}
- textValue=""
- >
-
-
-
-
-
- {!collapsed &&
- group.families.map(family => {
- // The active id may be the base or its -fast sibling; either
- // way this one family row represents both.
- const activeId =
- group.provider.slug === optionsProvider &&
- (optionsModel === family.id || optionsModel === family.fastId)
- ? optionsModel
- : null
-
- const isCurrent = activeId !== null
- const name = modelDisplayParts(family.id).name
- // Capabilities are looked up against the active/base id; the
- // -fast variant carries the same param support as its base.
- const caps = group.provider.capabilities?.[family.id]
-
- // Effective settings for this row: live session state when it's
- // the active model, otherwise its remembered preset (Hermes
- // defaults when unset). Row label AND submenu read from these so
- // they never disagree.
- const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
- const effEffort = isCurrent ? currentReasoningEffort : (preset.effort ?? '')
- const effFast = isCurrent ? currentFastMode : (preset.fast ?? false)
-
- const fastControl = resolveFastControl(
- activeId ?? family.id,
- group.provider.models ?? [],
- caps?.fast ?? false,
- effFast
- )
-
- const meta = [
- fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
- (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null
- ]
- .filter(Boolean)
- .join(' ')
-
- // Every row is a hover-Edit submenu trigger. Activating it
- // (pointer or keyboard) switches to the family's base model and
- // restores its preset; the Fast toggle inside swaps to the -fast
- // sibling (or flips the speed param). The sub-trigger has no
- // `onSelect`, so wire both click and Enter/Space for keyboard parity.
- // Clicking the row commits the model and closes the picker; the
- // edit submenu (reasoning/fast) is reached by HOVER, so you can
- // still tweak those without the click dismissing everything.
- const activate = () => {
- if (!isCurrent) {
- void selectFamily(family, group.provider)
- }
-
- closeMenu()
- }
-
- return (
-
- {
- if (event.key === 'Enter' || event.key === ' ') {
- activate()
- }
- }}
- {...kbRowProps(`${group.provider.slug}:${family.id}`)}
- >
-
-
- {meta ? {meta} : null}
-
- {isCurrent ? (
-
- ) : null}
-
- switchTo(nextModel, group.provider.slug)}
- provider={group.provider.slug}
- reasoning={caps?.reasoning ?? true}
- requestGateway={requestGateway}
- />
-
- )
- })}
-
- )
- })}
-
- )}
-
-
-
- {shownMoaPresets.length > 0 ? (
-
- MoA presets
- {shownMoaPresets.map(preset => {
- const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
-
- return (
- {
- event.preventDefault()
- void selectMoaPreset(preset)
- }}
- {...kbRowProps(`moa:${preset}`)}
- >
-
- MoA:
-
- {isCurrentMoa ? : null}
-
- )
- })}
-
-
- ) : null}
-
- {
- event.preventDefault()
- void refreshModels()
- }}
- >
-
- {copy.refreshModels}
-
-
- setModelVisibilityOpen(true)}
- >
-
- {copy.editModels}
-
- >
+ }
+ gateway={gateway}
+ includeMoa
+ profile={profile}
+ sessionId={activeSessionId}
+ />
)
}
-
-// Collapsed we show the user's chosen models (or the curated default); typing
-// spans every available model so anything is reachable past the cut. A search
-// is itself a narrowing action, so we do NOT cap per-provider matches — a
-// provider serving 19 models (e.g. opencode-go) must show all 19 when the user
-// searches for it, not a truncated subset. (#47077 follow-up)
-
-function groupModels(
- providers: ModelOptionProvider[],
- search: string,
- current: { model: string; provider: string },
- visible: Set | null
-): ProviderGroup[] {
- const q = normalize(search)
- const groups: ProviderGroup[] = []
-
- for (const provider of providers) {
- const allFamilies = collapseModelFamilies(provider.models ?? [])
-
- if (allFamilies.length === 0) {
- continue
- }
-
- const matches = (family: ModelFamily) =>
- `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
- .toLowerCase()
- .includes(q)
-
- // Which model ids to show (the active one is always added on top of this).
- let shown: Set
-
- if (q) {
- // Search spans every family, regardless of visibility.
- shown = new Set(allFamilies.filter(matches).map(family => family.id))
- } else if (visible) {
- // User has customized which models show — honor their selection exactly.
- shown = new Set(
- allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
- )
- } else {
- // Default: curated top-N families per provider.
- shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
- }
-
- // Always include the active model — but keep every row in the provider's
- // stable curated order (filter `allFamilies`, never reorder), so selecting
- // a model can't shuffle the list. While SEARCHING, the pin is skipped: a
- // query means "show me matches", and a pinned non-match sitting above them
- // reads like the top result (type "grok", see the current Fable first).
- const activeId =
- !q && provider.slug === current.provider && current.model
- ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
- : undefined
-
- const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
-
- if (families.length > 0) {
- groups.push({ families, provider })
- }
- }
-
- // Stable, logical group order: alphabetical by provider name. (The backend
- // floats the current provider first, which would reshuffle on every switch.)
- groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
-
- return groups
-}
diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx
index 5b069703a255a..2663fe7c86349 100644
--- a/apps/desktop/src/plugins/kanban/board.tsx
+++ b/apps/desktop/src/plugins/kanban/board.tsx
@@ -78,6 +78,12 @@ import {
} from './api'
import { BoardSwitcher } from './board-switcher'
import { TaskDrawer } from './drawer'
+import {
+ EMPTY_OVERRIDE,
+ ModelOverrideField,
+ overrideCreateFields,
+ type TaskModelOverride
+} from './model-override'
import { OrchestrationPanel } from './orchestration'
import { columnMeta, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types'
import {
@@ -570,6 +576,7 @@ function NewTaskDialog({
// a path here overrides just this task. Only meaningful for dir/worktree.
const [workspacePath, setWorkspacePath] = useState('')
const [parent, setParent] = useState('')
+ const [modelOverride, setModelOverride] = useState(EMPTY_OVERRIDE)
const [goalMode, setGoalMode] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
@@ -602,6 +609,7 @@ function NewTaskDialog({
setWorkspaceKind(boardDefaultKind)
setWorkspacePath('')
setParent('')
+ setModelOverride(EMPTY_OVERRIDE)
setGoalMode(false)
setError(null)
setBusy(false)
@@ -637,6 +645,7 @@ function NewTaskDialog({
title: trimmed,
triage: isTriage,
workspace_kind: workspaceKind,
+ ...overrideCreateFields(modelOverride),
// Empty → backend inherits the board's default project dir.
workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined
})
@@ -661,7 +670,15 @@ function NewTaskDialog({
return (