diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx
index 377cbf1cda..62e1a59234 100644
--- a/apps/desktop/src/app/chat/composer/controls.tsx
+++ b/apps/desktop/src/app/chat/composer/controls.tsx
@@ -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({
{minimal ? null : (
<>
- {hideModelPill ? null :
}
+ {hideModelPill ? null : (
+ <>
+
+ {compactModelPill ? null :
}
+ >
+ )}
{voiceControls}
>
)}
diff --git a/apps/desktop/src/app/chat/composer/model-pill.test.tsx b/apps/desktop/src/app/chat/composer/model-pill.test.tsx
index bf7acb2ac9..6d569d6fa1 100644
--- a/apps/desktop/src/app/chat/composer/model-pill.test.tsx
+++ b/apps/desktop/src/app/chat/composer/model-pill.test.tsx
@@ -158,7 +158,7 @@ describe('ModelPill per-surface model label', () => {
)
- expect(screen.getByText('Sonnet · High')).toBeTruthy()
+ expect(screen.getByText('Sonnet')).toBeTruthy()
expect(screen.queryByText(/primary/i)).toBeNull()
})
})
diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx
index 5d8cef2bfd..0e92932785 100644
--- a/apps/desktop/src/app/chat/composer/model-pill.tsx
+++ b/apps/desktop/src/app/chat/composer/model-pill.tsx
@@ -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() ? (
- {formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })}
+ {formatModelPillLabel(currentModel, { fastMode })}
) : (
diff --git a/apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx b/apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx
new file mode 100644
index 0000000000..c79aa7d996
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx
@@ -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'] => ({
+ canSwitch: true,
+ model: 'gpt-6',
+ provider: 'openai',
+ reasoningMenuContent: menu
,
+ ...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(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(
+
+
+
+ )
+
+ expect(screen.getByTestId('reasoning-pill').textContent).toBe('Low')
+ unmount()
+
+ render(
+
+
+
+ )
+
+ 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(
+
+
+
+ )
+
+ expect(screen.queryByTestId('reasoning-pill')).toBeNull()
+ unmount()
+
+ render(
+
+
+
+ )
+
+ expect(screen.getByTestId('reasoning-pill')).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/reasoning-pill.tsx b/apps/desktop/src/app/chat/composer/reasoning-pill.tsx
new file mode 100644
index 0000000000..6df4a81535
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/reasoning-pill.tsx
@@ -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 (
+
+
+
+
+
+
+
+ setMenuOpen(false)}>
+ {model.reasoningMenuContent}
+
+
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts
index dc78bf942d..40f4400af4 100644
--- a/apps/desktop/src/app/chat/composer/types.ts
+++ b/apps/desktop/src/app/chat/composer/types.ts
@@ -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 }
diff --git a/apps/desktop/src/app/chat/index.test.tsx b/apps/desktop/src/app/chat/index.test.tsx
index 52571fcba3..7d21051076 100644
--- a/apps/desktop/src/app/chat/index.test.tsx
+++ b/apps/desktop/src/app/chat/index.test.tsx
@@ -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: [] }))
}))
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 94304b28c3..f7cb706d94 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -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, 'onSubmit'> {
modelOptionsOwnerConnectionId?: string
modelOptionsProfile?: string
modelMenuContent?: React.ReactNode
+ reasoningMenuContent?: React.ReactNode
requestModelOptionsForOwner?: (method: string, params?: Record) => Promise
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(
() => ({
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
diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx
index ec8e6e6c78..7a7822d64e 100644
--- a/apps/desktop/src/app/chat/session-tile.tsx
+++ b/apps/desktop/src/app/chat/session-tile.tsx
@@ -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 ? (
+
+ ) : null,
+ [
+ activeGatewayProfile,
+ gatewayOpen,
+ ownerRoute?.connectionId,
+ ownerRoute?.profile,
+ ownerRoute?.targetProfile,
+ requestTileGateway,
+ selectModel
+ ]
+ )
+
return (
@@ -327,6 +349,7 @@ function TileChat({
onThreadMessagesChange={actions.handleThreadMessagesChange}
onToggleSelectedPin={noop}
onTranscribeAudio={tileTranscribeAudio}
+ reasoningMenuContent={reasoningMenuContent}
requestModelOptionsForOwner={requestTileGateway}
/>
diff --git a/apps/desktop/src/app/contrib/surfaces.test.tsx b/apps/desktop/src/app/contrib/surfaces.test.tsx
index 361590226c..28040d8b65 100644
--- a/apps/desktop/src/app/contrib/surfaces.test.tsx
+++ b/apps/desktop/src/app/contrib/surfaces.test.tsx
@@ -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()
diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx
index 0c367c5e3d..5bc3e49307 100644
--- a/apps/desktop/src/app/contrib/surfaces.tsx
+++ b/apps/desktop/src/app/contrib/surfaces.tsx
@@ -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' ? (
+
+ ) : 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}
/>
diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx
index 68b7281345..cafc938876 100644
--- a/apps/desktop/src/app/shell/model-edit-submenu.tsx
+++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx
@@ -96,12 +96,14 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
// row made opening the menu itself lag on large catalogs.
return (
-
+
)
}
-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,
diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx
index a63d8da6d5..844224126a 100644
--- a/apps/desktop/src/app/shell/model-menu-panel.tsx
+++ b/apps/desktop/src/app/shell/model-menu-panel.tsx
@@ -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 | void
- profile?: string
- requestGateway: (method: string, params?: Record) => Promise
-}
+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 =>
- 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 (
+ {t.shell.modelOptions.noOptions}
+
+ )
+ }
+
+ // 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 (
+ controller.select(nextModel, provider)}
+ onSetOptions={patch => controller.setOptions(patch, row)}
+ provider={provider}
+ reasoning={caps?.reasoning ?? true}
+ />
+ )
+}
diff --git a/apps/desktop/src/app/shell/use-model-menu-controller.ts b/apps/desktop/src/app/shell/use-model-menu-controller.ts
new file mode 100644
index 0000000000..c8bb3e951c
--- /dev/null
+++ b/apps/desktop/src/app/shell/use-model-menu-controller.ts
@@ -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 | void
+ profile?: string
+ requestGateway: (method: string, params?: Record) => Promise
+}
+
+/**
+ * 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 =>
+ 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 }
+}
diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts
index 1a94e2186b..be8a40a3b1 100644
--- a/apps/desktop/src/lib/model-options.ts
+++ b/apps/desktop/src/lib/model-options.ts
@@ -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
diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts
index f5dd3143ec..bf2b4a875e 100644
--- a/apps/desktop/src/lib/model-status-label.test.ts
+++ b/apps/desktop/src/lib/model-status-label.test.ts
@@ -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', () => {
diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts
index 62af16cd38..e2b2749120 100644
--- a/apps/desktop/src/lib/model-status-label.ts
+++ b/apps/desktop/src/lib/model-status-label.ts
@@ -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
}
diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md
index 14e7c7f456..bee47e4c6e 100644
--- a/website/docs/user-guide/desktop.md
+++ b/website/docs/user-guide/desktop.md
@@ -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.