From b05a47b9d2df4d62124a80f70d657c6b8e1b07fb Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:58:11 -0700 Subject: [PATCH] feat(desktop): reasoning effort gets its own composer pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer showed " · 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. --- .../src/app/chat/composer/controls.tsx | 8 +- .../src/app/chat/composer/model-pill.test.tsx | 2 +- .../src/app/chat/composer/model-pill.tsx | 8 +- .../app/chat/composer/reasoning-pill.test.tsx | 81 ++++++++ .../src/app/chat/composer/reasoning-pill.tsx | 81 ++++++++ apps/desktop/src/app/chat/composer/types.ts | 5 + apps/desktop/src/app/chat/index.test.tsx | 1 + apps/desktop/src/app/chat/index.tsx | 21 +- apps/desktop/src/app/chat/session-tile.tsx | 23 +++ .../desktop/src/app/contrib/surfaces.test.tsx | 1 + apps/desktop/src/app/contrib/surfaces.tsx | 16 ++ .../src/app/shell/model-edit-submenu.tsx | 6 +- .../src/app/shell/model-menu-panel.tsx | 191 +---------------- .../src/app/shell/reasoning-menu-panel.tsx | 59 ++++++ .../app/shell/use-model-menu-controller.ts | 195 ++++++++++++++++++ apps/desktop/src/lib/model-options.ts | 13 +- .../src/lib/model-status-label.test.ts | 32 +-- apps/desktop/src/lib/model-status-label.ts | 29 +-- website/docs/user-guide/desktop.md | 2 +- 19 files changed, 529 insertions(+), 245 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/reasoning-pill.test.tsx create mode 100644 apps/desktop/src/app/chat/composer/reasoning-pill.tsx create mode 100644 apps/desktop/src/app/shell/reasoning-menu-panel.tsx create mode 100644 apps/desktop/src/app/shell/use-model-menu-controller.ts 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.