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:
parent
f0ed0aebbc
commit
b35c34d58c
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue