refactor(desktop): make the composer's model picker a reusable primitive

The model menu — search, provider grouping, -fast family collapse, keyboard
selection, the per-row thinking/effort submenu — was welded to the chat
composer's session writes, so any other surface wanting a model picker had to
fork it and drift.

Splits rendering from meaning. ModelCatalogMenu owns the catalog and the
navigation; a ModelMenuController decides what a selection DOES. The composer
is now one controller over it, keeping its session scoping, sticky manual
pick, preset restore, MoA presets, and rollback-on-failed-write intact.

ModelEditSubmenu becomes pure: it reports edits instead of performing them.
It previously called setCurrentReasoningEffort and config.set inline, so any
non-composer host would have silently retargeted the user's live chat when
they picked an effort. Its default effort is passed in rather than read from a
store, which is what lets it render outside a session at all.

Exported through the SDK so plugins consume the real component instead of a
copy. The composer's existing behaviour suite passes unchanged against it.
This commit is contained in:
Brooklyn Nicholson 2026-08-01 16:10:13 -05:00
parent f0ed0aebbc
commit b35c34d58c
5 changed files with 843 additions and 689 deletions

View File

@ -0,0 +1,573 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
dropdownMenuRow,
DropdownMenuSearch,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { usePointerQuiet } from '@/components/ui/keyboard-first'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import {
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
type ModelFamily,
modelVisibilityKey
} from '@/store/model-visibility'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import { $defaultReasoningEffort } from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
// Lets the host dropdown (model-pill, a kanban field trigger, …) hand the panel
// a way to dismiss itself so clicking a model row commits + closes, while the
// hover-revealed edit submenu (reasoning/fast) stays open to play with (its
// items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
/** One model choice, everything a caller needs to act on a selection.
* `effort` is '' for "inherit the default" and 'none' for thinking off. */
export interface ModelChoice {
effort: string
fast: boolean
model: string
provider: string
}
/**
* What a surface DOES with the catalog. The menu renders and navigates; the
* controller owns meaning the composer writes through to a live session,
* the kanban override just holds a value in dialog state.
*
* `presetFor` supplies the remembered settings shown on a non-active row.
* Returning `{}` is fine the row then shows Hermes' defaults.
*/
export interface ModelMenuController {
/** Restore a model's remembered settings after it is selected. Separate from
* `setOptions` because it is one atomic "apply this model's preset" write,
* not a user editing one control surfaces that write through to a session
* need to batch it. Values are already capability-gated by the menu. */
applyPreset: (preset: { effort?: string; fast?: boolean }, row: { model: string; provider: string }) => void
current: ModelChoice
presetFor: (provider: string, model: string) => { effort?: string; fast?: boolean }
/** Commit a model row. Return false to abort (a failed session switch). */
select: (model: string, provider: string) => Promise<boolean | void> | void
/** Edit ONE option on a row. `isActive` says whether it's the current model. */
setOptions: (
patch: { effort?: string; fast?: boolean },
row: { isActive: boolean; model: string; provider: string }
) => void
}
interface ModelCatalogMenuProps {
controller: ModelMenuController
/** Rows appended under the catalog (Refresh Models, Edit Models, …). */
footer?: ReactNode
gateway?: HermesGateway
/** Render the virtual `moa` provider's presets as a selectable section.
* Off for override surfaces, where a MoA preset isn't a worker model. */
includeMoa?: boolean
profile?: string
/** The user's STORED visible-model keys (null = never customized). Resolved
* against the fetched catalog inside the menu a caller can't resolve it
* early against an unpopulated cache without hiding every row. Pass
* `undefined` to skip visibility filtering entirely. */
visibleModels?: Set<string> | null
}
interface ProviderGroup {
families: ModelFamily[]
provider: ModelOptionProvider
}
/**
* THE model catalog menu: searchable, provider-grouped, `-fast` families
* collapsed to one row, per-row hover submenu for thinking/effort/fast, full
* keyboard selection. Shared verbatim by the composer's model pill and by
* plugin surfaces that pick a model without a session behind it so the two
* can never drift apart.
*/
export function ModelCatalogMenu({
controller,
footer,
gateway,
includeMoa = false,
profile = 'default',
visibleModels
}: ModelCatalogMenuProps) {
const { t } = useI18n()
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
const collapsedProviders = useStoreCollapsed()
const defaultEffort = useDefaultEffort()
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, null),
// Gateway-first even with no session: a connected (possibly remote)
// gateway owns the model catalog, including virtual providers the local
// REST fallback can't know about (#53817).
queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway })
})
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. Keep it
// out of the main groups so presets never show up twice.
const moaPresets = useMemo(
() => (includeMoa ? (providers?.find(p => p.slug.toLowerCase() === 'moa')?.models ?? []) : []),
[providers, includeMoa]
)
const pickerProviders = useMemo(
() => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [],
[providers]
)
const current = controller.current
// Resolve visibility HERE, against the catalog we actually fetched: an empty
// provider list would otherwise resolve to an empty key set that reads as
// "user hid everything" and blanks the menu on first open.
const shownKeys = useMemo(
() => (visibleModels === undefined ? null : effectiveVisibleKeys(visibleModels, pickerProviders)),
[visibleModels, pickerProviders]
)
const groups = useMemo(
() => groupModels(pickerProviders, search, { model: current.model, provider: current.provider }, shownKeys),
[pickerProviders, search, current.model, current.provider, shownKeys]
)
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]
)
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = controller.presetFor(provider.slug, family.id)
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the remembered preset by selecting that sibling. Param-fast
// is applied through setOptions below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await controller.select(targetId, provider.slug)) === false) {
return
}
controller.applyPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ model: family.id, provider: provider.slug }
)
}
const selectMoaPreset = async (preset: string) => {
if ((await controller.select(preset, 'moa')) === false) {
return
}
closeMenu()
}
// ── 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.
type KbRow =
| { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider }
| { key: string; kind: 'moa'; preset: string }
const kbRows = useMemo<KbRow[]>(
() => [
...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 | number>(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.
const pointerQuiet = usePointerQuiet()
const currentKey = current.provider === 'moa' ? `moa:${current.model}` : `${current.provider}:${current.model}`
const autoIndex = q
? kbRows.length > 0
? 0
: -1
: kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === current.model))
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 !== current.model) {
void selectFamily(row.family, row.provider)
}
closeMenu()
}
// Keep the selected row in view while arrowing through the scrollable list.
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
}, [kbActiveKey])
const kbRowProps = (key: string) => {
const active = kbActiveKey === key
return {
className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'),
...(active ? { 'data-kb-active': '' } : {})
}
}
// Rows are hover-selectable, so they go inert with the pointer.
const quietRows = pointerQuiet && 'pointer-events-none'
return (
<>
<DropdownMenuSearch
aria-label={copy.search}
onKeyDown={event => {
// 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}
/>
<DropdownMenuSeparator className="mx-0" />
{loading ? (
<DropdownMenuGroup className="py-1">
{Array.from({ length: 4 }, (_, index) => (
<DropdownMenuItem
className={dropdownMenuRow}
disabled
key={index}
onSelect={event => event.preventDefault()}
>
<Skeleton className="h-4 w-full" />
</DropdownMenuItem>
))}
</DropdownMenuGroup>
) : error ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{error}
</DropdownMenuItem>
) : groups.length === 0 && moaPresets.length === 0 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{copy.noModels}
</DropdownMenuItem>
) : (
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
{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 (
<DropdownMenuGroup className="py-0.5" key={slug}>
<DropdownMenuItem
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
onSelect={event => {
event.preventDefault()
toggleCollapsedProvider(slug)
}}
textValue=""
>
<span className="truncate">
<HighlightMatches query={search} text={group.provider.name} />
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
size="0.625rem"
/>
</DropdownMenuItem>
{!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 (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
hideChevron
onClick={activate}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
activate()
}
}}
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
>
<span className="min-w-0 flex-1 truncate">
<HighlightMatches query={search} text={name} />
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
</span>
{isCurrent ? (
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
) : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
defaultEffort={defaultEffort}
effort={effEffort}
fastControl={fastControl}
isActive={isCurrent}
model={family.id}
onSelectModel={nextModel => 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}
/>
</DropdownMenuSub>
)
})}
</DropdownMenuGroup>
)
})}
</div>
)}
{shownMoaPresets.length > 0 ? (
<div className={cn(quietRows)}>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{shownMoaPresets.map(preset => {
const isCurrentMoa = current.provider === 'moa' && current.model === preset
return (
<DropdownMenuItem
key={`moa:${preset}`}
onSelect={event => {
event.preventDefault()
void selectMoaPreset(preset)
}}
{...kbRowProps(`moa:${preset}`)}
>
<span className="min-w-0 flex-1 truncate">
MoA: <HighlightMatches query={search} text={preset} />
</span>
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>
)
})}
</div>
) : null}
{footer ? (
<>
<DropdownMenuSeparator className="mx-0" />
{footer}
</>
) : null}
</>
)
}
/** 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<string> | 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<string>
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
}

View File

@ -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<typeof HermesApi>()
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<unknown> }) {
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(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
defaultEffort={opts.defaultEffort ?? 'medium'}
effort={opts.effort ?? 'medium'}
fastControl={opts.fastControl}
isActive
isActive={opts.isActive ?? true}
model="m1"
onSelectModel={vi.fn()}
onSelectModel={opts.onSelectModel ?? vi.fn()}
onSetOptions={opts.onSetOptions}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
@ -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')
})
})

View File

@ -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<boolean> | void
/** This row's provider slug — edits persist as its global preset. */
onSelectModel: (model: string) => Promise<boolean | void> | 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: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
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({
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')}
onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })}
size="xs"
/>
</DropdownMenuItem>
@ -250,7 +160,7 @@ function ModelEditSubmenuBody({
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
<DropdownMenuRadioGroup onValueChange={value => onSetOptions({ effort: value })} value={effortValue}>
{REASONING_EFFORTS.map(value => (
<DropdownMenuRadioItem
className={dropdownMenuRow}

View File

@ -1,51 +1,31 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
dropdownMenuRow,
DropdownMenuSearch,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { usePointerQuiet } from '@/components/ui/keyboard-first'
import { Skeleton } from '@/components/ui/skeleton'
import { DropdownMenuItem, dropdownMenuRow } from '@/components/ui/dropdown-menu'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text'
import { currentPickerSelection } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import { $modelPresets, applyModelPreset, modelPresetKey, setModelPreset } from '@/store/model-presets'
import { $visibleModels, setModelVisibilityOpen } from '@/store/model-visibility'
import { notifyError } from '@/store/notifications'
import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import { $defaultReasoningEffort } from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
$defaultReasoningEffort,
markComposerSelectionManual,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
// clicking a model row commits + closes, while the hover-revealed edit submenu
// (reasoning/fast) stays open to play with (its items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
export { ModelMenuCloseContext } from './model-catalog-menu'
export interface ModelSelection {
model: string
@ -62,16 +42,15 @@ interface ModelMenuPanelProps {
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
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,57 +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'
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<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId: activeSessionId })
})
const cached = queryClient.getQueryData<ModelOptionsResponse>(modelOptionsQueryKey(profile, activeSessionId))
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
{ model: currentModel, provider: currentProvider },
modelOptions.data
cached
)
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 +99,148 @@ 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<KbRow[]>(
() => [
...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 | number>(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<HTMLDivElement>(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 (
<>
<DropdownMenuSearch
aria-label={copy.search}
onKeyDown={event => {
// 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}
/>
<ModelCatalogMenu
controller={controller}
footer={
<>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
void refreshModels()
}}
>
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-0" />
{loading ? (
<DropdownMenuGroup className="py-1">
{Array.from({ length: 4 }, (_, index) => (
<DropdownMenuItem
className={dropdownMenuRow}
disabled
key={index}
onSelect={event => event.preventDefault()}
>
<Skeleton className="h-4 w-full" />
</DropdownMenuItem>
))}
</DropdownMenuGroup>
) : error ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{error}
</DropdownMenuItem>
) : groups.length === 0 && moaPresets.length === 0 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{copy.noModels}
</DropdownMenuItem>
) : (
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
{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 (
<DropdownMenuGroup className="py-0.5" key={slug}>
<DropdownMenuItem
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
onSelect={event => {
event.preventDefault()
toggleCollapsedProvider(slug)
}}
textValue=""
>
<span className="truncate">
<HighlightMatches query={search} text={group.provider.name} />
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
size="0.625rem"
/>
</DropdownMenuItem>
{!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 (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
hideChevron
onClick={activate}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
activate()
}
}}
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
>
<span className="min-w-0 flex-1 truncate">
<HighlightMatches query={search} text={name} />
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
</span>
{isCurrent ? (
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
) : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort={effEffort}
fastControl={fastControl}
isActive={isCurrent}
model={family.id}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>
</DropdownMenuSub>
)
})}
</DropdownMenuGroup>
)
})}
</div>
)}
<DropdownMenuSeparator className="mx-0" />
{shownMoaPresets.length > 0 ? (
<div className={cn(quietRows)}>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{shownMoaPresets.map(preset => {
const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
return (
<DropdownMenuItem
key={`moa:${preset}`}
onSelect={event => {
event.preventDefault()
void selectMoaPreset(preset)
}}
{...kbRowProps(`moa:${preset}`)}
>
<span className="min-w-0 flex-1 truncate">
MoA: <HighlightMatches query={search} text={preset} />
</span>
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator className="mx-0" />
</div>
) : null}
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
void refreshModels()
}}
>
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
<Codicon name="settings-gear" size="0.75rem" />
{copy.editModels}
</DropdownMenuItem>
</>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
<Codicon name="settings-gear" size="0.75rem" />
{copy.editModels}
</DropdownMenuItem>
</>
}
gateway={gateway}
includeMoa
profile={profile}
visibleModels={visibleModels}
/>
)
}
// 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<string> | 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<string>
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
}

View File

@ -122,6 +122,18 @@ export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddlewar
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes'
/** THE model catalog menu the same searchable, provider-grouped, family-
* collapsing picker the chat composer uses, including the per-row
* thinking/effort/fast submenu. Drive it with a `ModelMenuController`: the
* menu renders and navigates, your controller decides what a selection MEANS
* (write to a session, hold a per-task override, ). Never fork it a copy
* drifts from the composer the first time either side changes. */
export {
ModelCatalogMenu,
type ModelChoice,
ModelMenuCloseContext,
type ModelMenuController
} from '@/app/shell/model-catalog-menu'
export type { StatusbarItem } from '@/app/shell/statusbar-controls'
export type { TitlebarTool } from '@/app/shell/titlebar-controls'
@ -184,9 +196,6 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
export { Textarea } from '@/components/ui/textarea'
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type { GatewayEventListener } from '@/contrib/events'
// -- contracts ----------------------------------------------------------------
export type {
HermesPlugin,
PluginContext,
@ -194,6 +203,9 @@ export type {
PluginRestOptions,
PluginStorage
} from '@/contrib/plugin'
// -- contracts ----------------------------------------------------------------
/** Mount-scoped contribution: while the rendering component is mounted, its
* children render in the target area's slot; unmount disposes it. Use for
* page-owned chrome (a page's titlebar control leaves with the page)
@ -228,12 +240,21 @@ export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
* authors) + its translucent tag fill so plugin-rendered identities read
* the same hue as everywhere else. */
export { profileColor, profileColorSoft } from '@/lib/profile-color'
export const PANES_AREA = 'panes'
/** The shared client itself, for invalidation OUTSIDE React (e.g. a
* `ctx.socket` frame invalidating a query). Inside components keep using
* `useQueryClient`. */
export { queryClient } from '@/lib/query-client'
export const PANES_AREA = 'panes'
/** Hermes' reasoning levels + their compact labels, so a plugin surfacing a
* thinking depth uses the same scale and spelling as the rest of the app. */
export {
DEFAULT_REASONING_EFFORT,
REASONING_EFFORT_VALUES,
REASONING_EFFORTS,
type ReasoningEffort,
reasoningEffortLabel
} from '@/lib/reasoning-effort'
export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const
export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const