feat(desktop): inline model picker in the status bar

Replace the status-bar model chip's modal with a Cursor-style dropdown:
- providers grouped by name in a stable order (no recency reshuffle on select)
- per-model hover-Edit submenu for reasoning effort + fast, gated by per-model
  capabilities now surfaced in the model.options payload
- unified Fast toggle: flips the speed=fast param where supported, else swaps
  to the model's `-fast` variant (base and variant collapse into one row)
- localStorage-backed "Edit Models" dialog to choose which models appear

Adds reusable dropdown primitives (DropdownMenuSearch, shared row/label
tokens, portaled + collision-aware submenus) and reads session state from
nanostores rather than prop-drilling, so editing options doesn't rebuild and
close the menu.
This commit is contained in:
Brooklyn Nicholson
2026-06-02 19:09:41 -05:00
parent c47b9d126f
commit ea4fe15631
18 changed files with 1198 additions and 81 deletions
@@ -1,9 +1,11 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons'
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@@ -11,8 +13,10 @@ import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$busy,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
@@ -34,6 +38,7 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
statusSnapshot: StatusResponse | null
@@ -48,14 +53,17 @@ export function useStatusbarItems({
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
statusSnapshot,
toggleCommandCenter
}: StatusbarItemsOptions) {
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
@@ -269,17 +277,51 @@ export function useStatusbarItems({
variant: 'text'
},
{
detail: currentProvider || '',
icon: <Cpu className="size-3" />,
id: 'model-summary',
label: currentModel || 'No model selected',
onSelect: () => setModelPickerOpen(true),
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
variant: 'action'
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? `Model · ${currentProvider}: ${currentModel || 'none'}`
: 'Switch model',
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? `${currentProvider} · ${currentModel || 'no model'}`
: 'Open model picker',
variant: 'action' as const
})
},
versionItem
],
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
[
busy,
contextBar,
contextUsage,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt,
turnStartedAt,
versionItem
]
)
const leftStatusbarItems = useMemo(
@@ -0,0 +1,228 @@
import { useStore } from '@nanostores/react'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
dropdownMenuRow,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSubContent
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
const EFFORT_OPTIONS = [
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Max' }
] as const
/** How "fast" is achieved for a given model — two different mechanisms:
* - `param`: the Anthropic/OpenAI `speed=fast` request parameter.
* - `variant`: a separate `…-fast` sibling model selected via the model field.
*/
export type FastControl =
| { kind: 'none' }
| { kind: 'param'; on: boolean }
| { kind: 'variant'; baseId: string; fastId: string; on: boolean }
/** Resolve the fast mechanism for a model: prefer the speed=fast parameter
* when the backend supports it, else fall back to a `…-fast` sibling model. */
export function resolveFastControl(
model: string,
providerModels: readonly string[],
paramSupported: boolean,
currentFastMode: boolean
): FastControl {
if (paramSupported) {
return { kind: 'param', on: currentFastMode }
}
if (/-fast$/i.test(model)) {
const baseId = model.replace(/-fast$/i, '')
// Only a toggle if there's a base to switch back to; otherwise it's a
// standalone fast model with no "off" state.
return providerModels.includes(baseId)
? { kind: 'variant', baseId, fastId: model, on: true }
: { kind: 'none' }
}
const fastId = `${model}-fast`
if (providerModels.includes(fastId)) {
return { kind: 'variant', baseId: model, fastId, on: false }
}
return { kind: 'none' }
}
interface ModelEditSubmenuProps {
/** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
/** Switch to this model. Awaited before applying edits when not active. */
onActivate: () => Promise<void> | void
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<void> | void
/** Whether this model supports reasoning effort. */
reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ModelEditSubmenu({
fastControl,
isActive,
onActivate,
onSelectModel,
reasoning,
requestGateway
}: ModelEditSubmenuProps) {
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effort = normalizeEffort(currentReasoningEffort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
// Reasoning/fast are session-scoped (they apply to the active model), so
// editing a non-active model first switches to it — otherwise the change
// would land on (and be capability-checked against) the wrong model.
const ensureActive = async () => {
if (!isActive) {
await onActivate()
}
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next)
try {
await ensureActive()
await requestGateway('config.set', {
key: 'reasoning',
session_id: activeSessionId ?? '',
value: next
})
} catch (err) {
setCurrentReasoningEffort(rollback)
notifyError(err, 'Model option update failed')
}
}
const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id — swap to it (or back to the base).
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
return
}
if (fastControl.kind === 'param') {
setCurrentFastMode(enabled)
void (async () => {
try {
await ensureActive()
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId ?? '',
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
setCurrentFastMode(!enabled)
notifyError(err, 'Fast mode update failed')
}
})()
}
}
const hasFast = fastControl.kind !== 'none'
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
return (
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
{!hasFast && !reasoning ? (
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">No options for this model</div>
) : (
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto cursor-pointer"
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Fast
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
</DropdownMenuItem>
) : null}
{reasoning ? (
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Effort</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
key={option.value}
onSelect={event => event.preventDefault()}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</>
) : null}
</>
)}
</DropdownMenuSubContent>
)
}
function isThinkingEnabled(effort: string): boolean {
// Empty = Hermes default (medium) = on; only an explicit "none" is off.
return (effort || 'medium').trim().toLowerCase() !== 'none'
}
function normalizeEffort(effort: string): string {
const value = (effort || 'medium').trim().toLowerCase()
// Thinking off → no effort selected in the radio group.
if (value === 'none') {
return ''
}
return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium'
}
@@ -0,0 +1,276 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
dropdownMenuRow,
DropdownMenuSearch,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
import {
$activeSessionId,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort
} from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
interface ModelMenuPanelProps {
gateway?: HermesGateway
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<void> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
interface ProviderGroup {
families: ModelFamily[]
provider: ModelOptionProvider
}
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
const [search, setSearch] = useState('')
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
// the parent to rebuild the menu content (which would close the dropdown).
const activeSessionId = useStore($activeSessionId)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gateway && activeSessionId) {
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
}
return getGlobalModelOptions()
}
})
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
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
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
[providers, search, optionsModel, optionsProvider, visibleModels]
)
return (
<>
<DropdownMenuSearch
aria-label="Search models"
onValueChange={setSearch}
placeholder="Search models"
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 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
No models found
</DropdownMenuItem>
) : (
<div className="max-h-80 overflow-y-auto py-0.5">
{groups.map(group => (
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
{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]
const fastActive = optionsModel === family.fastId || (isCurrent && currentFastMode)
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
const meta = isCurrent
? [fastActive ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId
? 'Fast'
: ''
// Every row is a hover-Edit submenu trigger. Clicking switches
// to the family's base model; the Fast toggle inside swaps to
// the -fast sibling (or flips the speed param) as appropriate.
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
className={cn(dropdownMenuRow, 'cursor-pointer')}
hideChevron
onClick={() => {
if (!isCurrent) {
void switchTo(family.id, group.provider.slug)
}
}}
>
<span className="min-w-0 flex-1 truncate">
{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
fastControl={resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
currentFastMode
)}
isActive={isCurrent}
onActivate={() => switchTo(family.id, group.provider.slug)}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>
</DropdownMenuSub>
)
})}
</DropdownMenuGroup>
))}
</div>
)}
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
Edit Models
</DropdownMenuItem>
</>
)
}
// Collapsed we show the user's chosen models (or the curated default); typing
// spans every available model so anything is reachable past the cut.
const PER_PROVIDER_SEARCH = 12
function groupModels(
providers: ModelOptionProvider[],
search: string,
current: { model: string; provider: string },
visible: Set<string> | null
): ProviderGroup[] {
const q = search.trim().toLowerCase()
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.
const activeId =
provider.slug === current.provider && current.model
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
: undefined
let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
if (q) {
families = families.slice(0, PER_PROVIDER_SEARCH)
}
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
}
@@ -26,6 +26,7 @@ export interface StatusbarItem {
disabled?: boolean
hidden?: boolean
href?: string
menuAlign?: 'center' | 'end' | 'start'
menuClassName?: string
menuContent?: ReactNode
menuItems?: readonly StatusbarMenuItem[]
@@ -104,7 +105,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
align={item.menuAlign ?? 'start'}
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
side="top"
sideOffset={8}