feat(desktop): reasoning effort gets its own composer pill

The composer showed "<model> · Med" in one truncating pill and the only way
to change the effort was to open the model menu, find the active model's
row, and hover it for the per-row options submenu. Users read the pill as
"this model is medium only" and never found the submenu.

- New `ReasoningPill` next to the model pill: shows the active model's live
  effort (session value, else the profile default) and opens the same
  Thinking / Fast / Effort rows the catalog submenu offers, for the active
  model only. Hidden when the catalog reports `reasoning: false`; stays
  while capabilities are unknown so it never flickers during the fetch.
  Folds away with the model pill in the compact composer stages.
- `useModelMenuController` (shell sibling) now owns the session write /
  preset / optimistic-store / rollback logic that lived inside
  `ModelMenuPanel`; the model menu and the new `ReasoningMenuPanel` share it
  so an edit from either surface is one code path. Tiles get their own
  pill bound to their SessionView, primary or tile — never the globals.
- `ModelOptionsContent` (the submenu body) is exported container-free so
  the pill's top-level menu renders it without a Radix Sub wrapper.
- The model pill drops the effort suffix (`formatModelPillLabel`: name +
  Fast); `formatModelStatusLabel` had no other caller and is removed.
- `currentModelCapabilities()` in lib/model-options resolves the active
  pick's caps through `catalogProviderMatches` (aliases, custom slugs).

Live (headless Electron + worktree `hermes serve`, CDP): before — one
pill "Deepseek V4 Flash · Low", no effort control; after — "Deepseek V4
Flash" + "Low" pill; pick High → `config.get reasoning` on the live
session returns high; a `reasoning:false` cap unmounts the pill; the
catalog row submenu still writes through and the pill mirrors it.

Credit: the dedicated-pill direction was proposed independently in
composer selector on current main with the shared-controller shape.
This commit is contained in:
teknium1
2026-09-13 05:58:11 -07:00
committed by Teknium
parent 7fbe16aed5
commit b05a47b9d2
19 changed files with 529 additions and 245 deletions
@@ -13,6 +13,7 @@ import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import { ReasoningPill } from './reasoning-pill'
import { StartVoiceButton } from './start-voice-button'
import type { ChatBarState, VoiceStatus } from './types'
import { VoiceMenu } from './voice-menu'
@@ -110,7 +111,12 @@ export function ComposerControls({
<div className="ml-auto flex min-w-0 shrink items-center gap-(--composer-control-gap)">
{minimal ? null : (
<>
{hideModelPill ? null : <ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />}
{hideModelPill ? null : (
<>
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
{compactModelPill ? null : <ReasoningPill disabled={disabled} model={state.model} />}
</>
)}
{voiceControls}
</>
)}
@@ -158,7 +158,7 @@ describe('ModelPill per-surface model label', () => {
</SessionViewProvider>
)
expect(screen.getByText('Sonnet · High')).toBeTruthy()
expect(screen.getByText('Sonnet')).toBeTruthy()
expect(screen.queryByText(/primary/i)).toBeNull()
})
})
@@ -12,9 +12,9 @@ import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { formatModelPillLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
import { $currentModelSource, setModelPickerOpen } from '@/store/session'
import { onComposerModelMenuRequest } from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
@@ -57,9 +57,7 @@ export function ModelPill({
const currentModel = model.model || viewModel
const currentProvider = model.provider || viewProvider
const fastMode = useStore(view.$fast)
const reasoningEffort = useStore(view.$reasoningEffort)
const modelSource = useStore($currentModelSource)
const defaultEffort = useStore($defaultReasoningEffort)
const runtimeId = useStore(view.$runtimeId)
const [open, setOpen] = useState(false)
const restoreSelection = useRef<(() => void) | null>(null)
@@ -132,7 +130,7 @@ export function ModelPill({
<>
{currentModel.trim() ? (
<span className="truncate">
{formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })}
{formatModelPillLabel(currentModel, { fastMode })}
</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
@@ -0,0 +1,81 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { $defaultReasoningEffort } from '@/store/session'
import { ReasoningPill } from './reasoning-pill'
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
canSwitch: true,
model: 'gpt-6',
provider: 'openai',
reasoningMenuContent: <div>menu</div>,
...over
})
const tileView = (reasoningEffort: string): SessionView => ({
kind: 'tile',
$awaitingResponse: atom(false),
$busy: atom(false),
$cwd: atom(''),
$fast: atom(false),
$lastVisibleIsUser: atom(false),
$messages: atom([]),
$messagesEmpty: atom(true),
$model: atom('tile/claude-sonnet'),
$provider: atom('anthropic'),
$reasoningEffort: atom(reasoningEffort),
$runtimeId: atom('tile-runtime'),
$storedId: atom('stored-tile'),
$turnStartedAt: atom<number | null>(null)
})
afterEach(() => {
cleanup()
$defaultReasoningEffort.set('')
})
describe('ReasoningPill', () => {
it("shows THIS surface's live effort, falling back to the profile default when the session has none", () => {
$defaultReasoningEffort.set('high')
const { unmount } = render(
<SessionViewProvider value={tileView('low')}>
<ReasoningPill disabled={false} model={modelState()} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill').textContent).toBe('Low')
unmount()
render(
<SessionViewProvider value={tileView('')}>
<ReasoningPill disabled={false} model={modelState()} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill').textContent).toBe('High')
})
it('hides when the catalog says the model has no reasoning control, but not while that is unknown', () => {
const { unmount } = render(
<SessionViewProvider value={tileView('medium')}>
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: false })} />
</SessionViewProvider>
)
expect(screen.queryByTestId('reasoning-pill')).toBeNull()
unmount()
render(
<SessionViewProvider value={tileView('medium')}>
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: undefined })} />
</SessionViewProvider>
)
expect(screen.getByTestId('reasoning-pill')).toBeTruthy()
})
})
@@ -0,0 +1,81 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { $defaultReasoningEffort } from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer reasoning selector: the active model's effort level as its own
* pill next to the model pill, opening the same Thinking / Fast / Effort rows
* the catalog offers per model — without having to find the model's row and
* hover its submenu. Hidden when the catalog says the model has no reasoning
* control, and while there is no live menu (gateway closed).
*
* Reads THIS surface's SessionView (primary or tile), like the model pill.
*/
export function ReasoningPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.modelOptions
const view = useSessionView()
const reasoningEffort = useStore(view.$reasoningEffort)
const defaultEffort = useStore($defaultReasoningEffort)
const [open, setOpen] = useState(false)
if (!model.reasoningMenuContent || model.supportsReasoning === false) {
return null
}
const label = reasoningEffortLabel(reasoningEffort || defaultEffort || DEFAULT_REASONING_EFFORT)
const title = `${copy.effort}: ${label}`
// Closing the menu ends its claim on the keyboard: Radix restores focus to
// this pill (a toolbar button), so without the release the Enter that
// committed a level also swallows whatever you type next.
const setMenuOpen = (next: boolean) => {
setOpen(next)
if (!next) {
releaseTypingFocus()
}
}
return (
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={PILL}
data-testid="reasoning-pill"
disabled={disabled}
type="button"
variant="ghost"
>
<span>{label}</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-52 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setMenuOpen(false)}>
{model.reasoningMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -26,6 +26,11 @@ export interface ChatBarState {
quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
/** The reasoning pill's dropdown (same host + controller as the model menu). */
reasoningMenuContent?: ReactNode
/** False when the catalog says the active model has no reasoning control;
* undefined while unknown (loading) so the pill stays put. */
supportsReasoning?: boolean
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }
+1
View File
@@ -43,6 +43,7 @@ vi.mock('@/components/Backdrop', async () => {
vi.mock('@/components/prompt-overlays', () => ({ PromptOverlays: () => null }))
vi.mock('@/components/chat/vibe-hearts', () => ({ COMPOSER_HEART_CONFIG: {}, HeartField: () => null }))
vi.mock('@/lib/model-options', () => ({
currentModelCapabilities: () => undefined,
modelOptionsQueryKey: (...parts: unknown[]) => ['model-options', ...parts],
requestModelOptions: vi.fn(async () => ({ models: [] }))
}))
+18 -3
View File
@@ -24,7 +24,7 @@ import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentModelCapabilities, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { migrateSessionDraft } from '@/store/composer'
@@ -85,6 +85,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
modelOptionsOwnerConnectionId?: string
modelOptionsProfile?: string
modelMenuContent?: React.ReactNode
reasoningMenuContent?: React.ReactNode
requestModelOptionsForOwner?: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
@@ -381,6 +382,7 @@ const ChatViewContent = memo(function ChatViewContent({
modelOptionsOwnerConnectionId,
modelOptionsProfile,
modelMenuContent,
reasoningMenuContent,
requestModelOptionsForOwner,
onToggleSelectedPin,
onDeleteSelectedSession,
@@ -583,6 +585,8 @@ const ChatViewContent = memo(function ChatViewContent({
[currentModel, currentProvider, modelOptionsQuery.data]
)
const supportsReasoning = currentModelCapabilities(modelOptionsQuery.data, currentProvider, currentModel)?.reasoning
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
@@ -591,7 +595,9 @@ const ChatViewContent = memo(function ChatViewContent({
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels
quickModels,
reasoningMenuContent,
supportsReasoning
},
tools: {
enabled: true,
@@ -603,7 +609,16 @@ const ChatViewContent = memo(function ChatViewContent({
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
[
contextSuggestions,
currentModel,
currentProvider,
gatewayOpen,
modelMenuContent,
quickModels,
reasoningMenuContent,
supportsReasoning
]
)
// Drop files anywhere in the conversation area, not just on the composer
@@ -24,6 +24,7 @@ import { useModelControls } from '@/app/session/hooks/use-model-controls'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { ReasoningMenuPanel } from '@/app/shell/reasoning-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
@@ -295,6 +296,27 @@ function TileChat({
]
)
const reasoningMenuContent = useMemo(
() =>
gatewayOpen ? (
<ReasoningMenuPanel
onSelectModel={selectModel}
ownerConnectionId={ownerRoute?.connectionId || undefined}
profile={ownerRoute?.targetProfile || ownerRoute?.profile || activeGatewayProfile}
requestGateway={requestTileGateway}
/>
) : null,
[
activeGatewayProfile,
gatewayOpen,
ownerRoute?.connectionId,
ownerRoute?.profile,
ownerRoute?.targetProfile,
requestTileGateway,
selectModel
]
)
return (
<SessionViewProvider value={view}>
<ComposerScopeProvider value={scope}>
@@ -327,6 +349,7 @@ function TileChat({
onThreadMessagesChange={actions.handleThreadMessagesChange}
onToggleSelectedPin={noop}
onTranscribeAudio={tileTranscribeAudio}
reasoningMenuContent={reasoningMenuContent}
requestModelOptionsForOwner={requestTileGateway}
/>
</ComposerScopeProvider>
@@ -37,6 +37,7 @@ vi.mock('../routes', () => ({
vi.mock('./latest-actions', () => ({ latestChatActions: () => ({}), latestSidebarActions: () => ({}) }))
vi.mock('./panes', () => ({ setStatusbarItemGroup: vi.fn(), useStatusbarContributions: () => [] }))
vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null }))
vi.mock('../shell/reasoning-menu-panel', () => ({ ReasoningMenuPanel: () => null }))
afterEach(() => {
cleanup()
+16
View File
@@ -25,6 +25,7 @@ import { contributedRoutes, NEW_CHAT_ROUTE, ROUTES_AREA, sessionRoute } from '..
import { useStatusSnapshot } from '../shell/hooks/use-status-snapshot'
import { useStatusbarItems } from '../shell/hooks/use-statusbar-items'
import { ModelMenuPanel } from '../shell/model-menu-panel'
import { ReasoningMenuPanel } from '../shell/reasoning-menu-panel'
import { StatusbarControls } from '../shell/statusbar-controls'
import { latestChatActions, latestSidebarActions } from './latest-actions'
@@ -138,6 +139,20 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
[actions, activeConnectionId, activeGatewayProfile, gateway, gatewayState]
)
const reasoningMenuContent = useMemo(
() =>
gatewayState === 'open' ? (
<ReasoningMenuPanel
gateway={gateway || undefined}
onSelectModel={actions.selectModel}
ownerConnectionId={activeConnectionId || undefined}
profile={activeGatewayProfile}
requestGateway={actions.requestGateway}
/>
) : null,
[actions, activeConnectionId, activeGatewayProfile, gateway, gatewayState]
)
const chatActions = useMemo(() => latestChatActions(actions), [actions])
const chatView = (
@@ -147,6 +162,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
modelMenuContent={modelMenuContent}
modelOptionsOwnerConnectionId={activeConnectionId || undefined}
modelOptionsProfile={activeGatewayProfile}
reasoningMenuContent={reasoningMenuContent}
requestModelOptionsForOwner={actions.requestGateway}
{...chatActions}
/>
@@ -96,12 +96,14 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
// row made opening the menu itself lag on large catalogs.
return (
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
<ModelEditSubmenuBody {...props} />
<ModelOptionsContent {...props} />
</DropdownMenuSubContent>
)
}
function ModelEditSubmenuBody({
/** The options rows themselves, container-free: the catalog mounts them in a
* per-row submenu, the composer's reasoning pill in its own top-level menu. */
export function ModelOptionsContent({
canDisableReasoning,
defaultEffort,
effort,
+8 -183
View File
@@ -1,93 +1,30 @@
import type { ModelOptionsResponse } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
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 } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey, setModelPreset } from '@/store/model-presets'
import { $visibleModels } from '@/store/model-visibility'
import { notifyError } from '@/store/notifications'
import {
$defaultReasoningEffort,
markComposerSelectionManual,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
import { ModelCatalogMenu } from './model-catalog-menu'
import { type ModelMenuHostProps, useModelMenuController } from './use-model-menu-controller'
export { ModelMenuCloseContext } from './model-catalog-menu'
export interface ModelSelection {
model: string
provider: string
/** Runtime id of the surface that opened the menu. When set, the switch
* targets that session (a tile) instead of the primary `$activeSessionId`. */
sessionId?: null | string
}
interface ModelMenuPanelProps {
gateway?: HermesGateway
ownerConnectionId?: string
onSelectModel: (selection: ModelSelection) => Promise<boolean> | void
profile?: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export type { ModelSelection } from './use-model-menu-controller'
/**
* 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.
* controller that gives a selection its meaning HERE (`useModelMenuController`).
*/
export function ModelMenuPanel({
gateway,
onSelectModel,
ownerConnectionId,
profile = 'default',
requestGateway
}: ModelMenuPanelProps) {
export function ModelMenuPanel(props: ModelMenuHostProps) {
const { gateway, ownerConnectionId, profile = 'default', requestGateway } = props
const { t } = useI18n()
const copy = t.shell.modelMenu
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
// Bind to THIS surface's SessionView (primary or tile) so each pane's menu
// shows/switches its own model — not the primary-only globals.
const view = useSessionView()
const activeSessionId = useStore(view.$runtimeId)
const currentFastMode = useStore(view.$fast)
const currentModel = useStore(view.$model)
const currentProvider = useStore(view.$provider)
const currentReasoningEffort = useStore(view.$reasoningEffort)
const modelPresets = useStore($modelPresets)
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const visibleModels = useStore($visibleModels)
const touchesPrimary = view.kind === 'primary'
// Subscribe to the SAME query the menu runs (identical key ⇒ React Query
// dedupes, no second fetch). It must be a live subscription, not a cache
// peek: with no model in the session store yet, currentPickerSelection falls
// back to the catalog's reported current, and a non-reactive read would
// never repaint that fallback once the catalog resolved.
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, activeSessionId, ownerConnectionId),
queryFn: (): Promise<ModelOptionsResponse> =>
requestModelOptions({ gateway, profile, request: requestGateway, sessionId: activeSessionId })
})
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const { activeSessionId, controller } = useModelMenuController(props)
// 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
@@ -123,118 +60,6 @@ export function ModelMenuPanel({
}
}
// 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 }))
}
// 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
}
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: 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)
}
if (!row.isActive) {
return
}
if (patch.effort !== undefined) {
void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model)
}
if (patch.fast !== undefined) {
void patchFast(patch.fast, row.provider, row.model)
}
}
}
return (
<ModelCatalogMenu
controller={controller}
@@ -0,0 +1,59 @@
import { useStore } from '@nanostores/react'
import { useSessionView } from '@/app/chat/session-view'
import { DropdownMenuItem, dropdownMenuRow } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { catalogProviderMatches, currentModelCapabilities } from '@/lib/model-options'
import { ModelOptionsContent, resolveFastControl } from './model-edit-submenu'
import { type ModelMenuHostProps, useModelMenuController } from './use-model-menu-controller'
/**
* The composer's reasoning menu: the same options rows the catalog shows per
* model row (Thinking / Fast / Effort), opened directly for the ACTIVE model
* from its own pill. Same controller as the model menu, so an edit here and an
* edit in the catalog submenu are one code path — session write, preset,
* optimistic store, rollback.
*/
export function ReasoningMenuPanel(props: ModelMenuHostProps) {
const { t } = useI18n()
const view = useSessionView()
const currentModel = useStore(view.$model)
const { controller, defaultEffort, modelOptions } = useModelMenuController(props)
const { model, provider } = controller.current
const caps = currentModelCapabilities(modelOptions.data, provider, model)
if (!model) {
return (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{t.shell.modelOptions.noOptions}
</DropdownMenuItem>
)
}
// The catalog's provider row is what gates fast; the live model id (which
// may be the `-fast` sibling) decides which way the variant toggle points.
const providerModels = modelOptions.data?.providers?.find(p => catalogProviderMatches(p, provider))?.models ?? []
const row = { isActive: true, model, provider }
return (
<ModelOptionsContent
canDisableReasoning={caps?.can_disable_reasoning}
defaultEffort={defaultEffort}
effort={controller.current.effort}
fastControl={resolveFastControl(
currentModel || model,
providerModels,
caps?.fast ?? false,
controller.current.fast
)}
isActive
model={model}
onSelectModel={nextModel => controller.select(nextModel, provider)}
onSetOptions={patch => controller.setOptions(patch, row)}
provider={provider}
reasoning={caps?.reasoning ?? true}
/>
)
}
@@ -0,0 +1,195 @@
import type { ModelOptionsResponse } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useSessionView } from '@/app/chat/session-view'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentPickerSelection } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT } from '@/lib/reasoning-effort'
import { $modelPresets, applyModelPreset, modelPresetKey, 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 type { ModelMenuController } from './model-catalog-menu'
export interface ModelSelection {
model: string
provider: string
/** Runtime id of the surface that opened the menu. When set, the switch
* targets that session (a tile) instead of the primary `$activeSessionId`. */
sessionId?: null | string
}
export interface ModelMenuHostProps {
gateway?: HermesGateway
ownerConnectionId?: string
onSelectModel: (selection: ModelSelection) => Promise<boolean> | void
profile?: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
/**
* The controller that gives a model-menu edit its meaning on a chat surface —
* 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. Shared by the composer's model menu and its reasoning menu so both
* pills edit the same session through one code path.
*/
export function useModelMenuController({
gateway,
onSelectModel,
ownerConnectionId,
profile = 'default',
requestGateway
}: ModelMenuHostProps) {
const { t } = useI18n()
// Bind to THIS surface's SessionView (primary or tile) so each pane's menu
// shows/switches its own model — not the primary-only globals.
const view = useSessionView()
const activeSessionId = useStore(view.$runtimeId)
const currentFastMode = useStore(view.$fast)
const currentModel = useStore(view.$model)
const currentProvider = useStore(view.$provider)
const currentReasoningEffort = useStore(view.$reasoningEffort)
const modelPresets = useStore($modelPresets)
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const touchesPrimary = view.kind === 'primary'
// Subscribe to the SAME query the menu runs (identical key ⇒ React Query
// dedupes, no second fetch). It must be a live subscription, not a cache
// peek: with no model in the session store yet, currentPickerSelection falls
// back to the catalog's reported current, and a non-reactive read would
// never repaint that fallback once the catalog resolved.
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, activeSessionId, ownerConnectionId),
queryFn: (): Promise<ModelOptionsResponse> =>
requestModelOptions({ gateway, profile, request: requestGateway, sessionId: activeSessionId })
})
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
// 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 }))
}
// 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
}
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: 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)
}
if (!row.isActive) {
return
}
if (patch.effort !== undefined) {
void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model)
}
if (patch.fast !== undefined) {
void patchFast(patch.fast, row.provider, row.model)
}
}
}
return { activeSessionId, controller, defaultEffort, modelOptions }
}
+12 -1
View File
@@ -1,4 +1,4 @@
import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared'
import type { ModelCapabilities, ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
@@ -18,6 +18,17 @@ export function catalogProviderMatches(provider: CatalogProviderIdentity, curren
)
}
/** The catalog's option support for the current pick, or undefined while the
* catalog is loading / doesn't say. Callers treat undefined as "assume
* reasoning" so controls never flicker away during the fetch. */
export function currentModelCapabilities(
options: ModelOptionsResponse | null | undefined,
provider: string,
model: string
): ModelCapabilities | undefined {
return options?.providers?.find(row => catalogProviderMatches(row, provider))?.capabilities?.[model]
}
// A picked (provider, model) pair is never retargeted from catalog membership.
// Picker rows are hints (discovered / curated / capped lists); a custom endpoint
// or a newer release legitimately serves ids the row lacks, and the backend
@@ -1,11 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
currentPickerSelection,
displayModelName,
formatModelStatusLabel,
modelDisplayParts
} from './model-status-label'
import { currentPickerSelection, displayModelName, formatModelPillLabel, modelDisplayParts } from './model-status-label'
import { reasoningEffortLabel } from './reasoning-effort'
describe('model-status-label', () => {
@@ -41,26 +36,11 @@ describe('model-status-label', () => {
expect(reasoningEffortLabel('')).toBe('')
})
it('appends fast + effort session state to the status label', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe(
'GPT-5.5 · Fast High'
)
})
it('falls back to the profile default effort, then to medium', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
// No session-level effort → the configured profile default is advertised,
// not Hermes' built-in medium.
expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high' })).toBe('GPT-5.5 · High')
// An explicit session effort still wins over the profile default.
expect(formatModelStatusLabel('openai/gpt-5.5', { defaultEffort: 'high', reasoningEffort: 'low' })).toBe(
'GPT-5.5 · Low'
)
})
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
it('keeps the model pill to name + Fast; the effort lives on its own pill', () => {
expect(formatModelPillLabel('openai/gpt-5.5', { fastMode: true })).toBe('GPT-5.5 · Fast')
expect(formatModelPillLabel('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8 · Fast')
expect(formatModelPillLabel('openai/gpt-5.5')).toBe('GPT-5.5')
expect(formatModelPillLabel('')).toBe('No model')
})
describe('currentPickerSelection', () => {
+7 -22
View File
@@ -1,5 +1,3 @@
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
/** Which model/provider pair a picker should mark "current". SessionView state
* also drives the composer label, so a complete pair there wins over an older
* `model.options` response. During initial hydration (or pre-session startup),
@@ -109,30 +107,17 @@ export function displayModelName(model: string): string {
return modelDisplayParts(model).name
}
/** Status bar trigger label — model name plus the live session state (effort/fast).
* `defaultEffort` is the profile's configured level, used when the surface has
* no explicit effort so the label never advertises a default the agent won't use. */
export function formatModelStatusLabel(
model: string,
options?: { defaultEffort?: string; fastMode?: boolean; reasoningEffort?: string }
): string {
/** Composer model-pill label — model name plus Fast when it applies. The
* reasoning level is NOT here: it has its own pill (`ReasoningPill`), so a
* long model name can no longer push the effort out of the truncating span. */
export function formatModelPillLabel(model: string, options?: { fastMode?: boolean }): string {
const name = displayModelName(model)
if (!model.trim()) {
return name
}
const parts: string[] = []
// Fast is shown when the speed=fast param is on (options.fastMode) OR the
// active model is a `…-fast` variant (fast via a separate model id).
if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) {
parts.push('Fast')
if (model.trim() && (options?.fastMode || /-fast$/i.test(modelBaseId(model)))) {
return `${name} · Fast`
}
// Always surface the effort so the current reasoning level is visible at a
// glance, not just when non-default.
parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || DEFAULT_REASONING_EFFORT))
return `${name} · ${parts.join(' ')}`
return name
}
+1 -1
View File
@@ -88,7 +88,7 @@ Changing any of these values invalidates only that profile's disk-discovery cach
#### Choosing a model
The model picker lives in the **composer**, just left of the microphone. Click it to switch the model, reasoning effort, and fast mode from one dropdown.
The model picker lives in the **composer**, just left of the microphone. Click it to switch the model; hover a model row for its options (thinking, effort, fast). Next to it, a **reasoning pill** shows the active model's effort level (`Med`, `High`, …) and opens the same options directly, so you can change effort without finding the model's row. The pill is hidden for models whose catalog reports no reasoning control.
- **The composer picker is sticky UI state and never touches your default.** It's remembered locally (per device) and **follows** across new chats and restarts instead of snapping back to the default — pick a model once and the next `Cmd/Ctrl+N` opens on it. With a live chat, switching models scopes the change to that **current chat**; either way the selection rides along when the session is created/switched and is **never** written to the profile default — with one exception: on a fresh profile that has no `model.default`/`model.provider` configured yet, the first pick is persisted so the app has a real default instead of falling through to a stray API-key env var on restart. Persistence follows the same rule as `/model` (`model.persist_switch_by_default`); use **Settings → Model** to change the default deliberately. (Switching [profiles](#sessions--profiles) reseeds to that profile's own default.)
- **Set the default in Settings → Model.** That "main" model is your **per-profile global default** — it's what new chats, crons, subagents, and auxiliary tasks start from, and it's the only place that writes it. Each [profile](#sessions--profiles) keeps its own default.