From c92a95a130cc0e88b33f0336191c56d4c0fef8a9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 23:37:33 -0500 Subject: [PATCH 1/8] feat(desktop): move model selector from statusbar to composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the model pill to the composer, left of the mic. A new ModelPill reuses the live ModelMenuPanel dropdown verbatim (single click target) and the formatModelStatusLabel "Model · Fast Med" label, anchored to its right edge so the menu doesn't drift with model-name length. modelMenuContent now flows to ChatView instead of useStatusbarItems, and the status-bar model-summary item is removed; the pill subscribes to the model atoms directly and falls back to the full picker when the gateway is closed. --- .../src/app/chat/composer/controls.tsx | 2 + .../src/app/chat/composer/model-pill.tsx | 72 +++++++++++++++++++ apps/desktop/src/app/chat/composer/types.ts | 4 ++ apps/desktop/src/app/chat/index.tsx | 5 +- apps/desktop/src/app/desktop-controller.tsx | 2 +- .../app/shell/hooks/use-statusbar-items.tsx | 50 ------------- 6 files changed, 83 insertions(+), 52 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/model-pill.tsx diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 8bc1a2b7cf929..b79753804c1b5 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' +import { ModelPill } from './model-pill' import type { ChatBarState, VoiceStatus } from './types' export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' @@ -81,6 +82,7 @@ export function ComposerControls({ return (
+ {canSteer && ( diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx new file mode 100644 index 0000000000000..0ea963a3628fa --- /dev/null +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -0,0 +1,72 @@ +import { useStore } from '@nanostores/react' + +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { useI18n } from '@/i18n' +import { ChevronDown } from '@/lib/icons' +import { formatModelStatusLabel } from '@/lib/model-status-label' +import { cn } from '@/lib/utils' +import { + $currentFastMode, + $currentModel, + $currentProvider, + $currentReasoningEffort, + setModelPickerOpen +} from '@/store/session' + +import type { ChatBarState } from './types' + +const PILL = cn( + 'h-(--composer-control-size) max-w-40 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 model selector — the relocated status-bar pill. Reuses the live + * `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the + * full picker when the gateway is closed and no live menu exists. + */ +export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) { + const copy = useI18n().t.shell.statusbar + const currentModel = useStore($currentModel) + const currentProvider = useStore($currentProvider) + const fastMode = useStore($currentFastMode) + const reasoningEffort = useStore($currentReasoningEffort) + + const label = ( + <> + {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + + + ) + const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel + + if (!model.modelMenuContent) { + return ( + + ) + } + + return ( + + + + + + {model.modelMenuContent} + + + ) +} diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 36b3b8e6d3d89..6d9444a6d9330 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + import type { HermesGateway } from '@/hermes' import type { ComposerAttachment } from '@/store/composer' @@ -22,6 +24,8 @@ export interface ChatBarState { canSwitch: boolean loading?: boolean quickModels?: QuickModelOption[] + /** Reused status-bar dropdown (built with gateway + selectModel upstream). */ + modelMenuContent?: ReactNode } tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] } voice: { enabled: boolean; active: boolean } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index c9f525653e712..63983caaa1a5f 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -62,6 +62,7 @@ import { threadLoadingState } from './thread-loading' interface ChatViewProps extends Omit, 'onSubmit'> { gateway: HermesGateway | null + modelMenuContent?: React.ReactNode onToggleSelectedPin: () => void onDeleteSelectedSession: () => void onCancel: () => Promise | void @@ -250,6 +251,7 @@ function ChatRuntimeBoundary({ export function ChatView({ className, gateway, + modelMenuContent, onToggleSelectedPin, onDeleteSelectedSession, onCancel, @@ -346,6 +348,7 @@ export function ChatView({ provider: currentProvider, canSwitch: gatewayOpen, loading: !gatewayOpen || (!currentModel && !currentProvider), + modelMenuContent, quickModels }, tools: { @@ -358,7 +361,7 @@ export function ChatView({ active: false } }), - [contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] + [contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels] ) // Drop files anywhere in the conversation area, not just on the composer diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 5ff162a2ca4c9..e071a2a0ce603 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -859,7 +859,6 @@ export function DesktopController() { gatewayLogLines, gatewayState, inferenceStatus, - modelMenuContent, openAgents, freshDraftReady, openCommandCenterSection, @@ -981,6 +980,7 @@ export function DesktopController() { composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} onAttachDroppedItems={composer.attachDroppedItems} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 53ce2dcc15026..b9a2d715454bf 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,5 +1,4 @@ import { useStore } from '@nanostores/react' -import type { ReactNode } from 'react' import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' @@ -9,7 +8,6 @@ import { useI18n } from '@/i18n' import { Activity, AlertCircle, - ChevronDown, Clock, Command, Hash, @@ -19,7 +17,6 @@ import { Zap, ZapFilled } from '@/lib/icons' -import { formatModelStatusLabel } from '@/lib/model-status-label' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' @@ -30,16 +27,11 @@ import { $activeSessionId, $busy, $connection, - $currentFastMode, - $currentModel, - $currentProvider, - $currentReasoningEffort, $currentUsage, $sessionStartedAt, $turnStartedAt, $workingSessionIds, $yoloActive, - setModelPickerOpen, setYoloActive } from '@/store/session' import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' @@ -65,7 +57,6 @@ interface StatusbarItemsOptions { gatewayLogLines: readonly string[] gatewayState: string inferenceStatus: RuntimeReadinessResult | null - modelMenuContent?: ReactNode openAgents: () => void openCommandCenterSection: (section: CommandCenterSection) => void freshDraftReady: boolean @@ -83,7 +74,6 @@ export function useStatusbarItems({ gatewayLogLines, gatewayState, inferenceStatus, - modelMenuContent, openAgents, openCommandCenterSection, freshDraftReady, @@ -97,10 +87,6 @@ export function useStatusbarItems({ const terminalTakeover = useStore($terminalTakeover) const yoloActive = useStore($yoloActive) const busy = useStore($busy) - const currentFastMode = useStore($currentFastMode) - const currentModel = useStore($currentModel) - const currentProvider = useStore($currentProvider) - const currentReasoningEffort = useStore($currentReasoningEffort) const currentUsage = useStore($currentUsage) const desktopActionTasks = useStore($desktopActionTasks) const previewServerRestartStatus = useStore($previewServerRestartStatus) @@ -416,37 +402,6 @@ export function useStatusbarItems({ title: yoloActive ? copy.yoloOn : copy.yoloOff, variant: 'action' }, - { - id: 'model-summary', - label: ( - - - {formatModelStatusLabel(currentModel, { - fastMode: currentFastMode, - reasoningEffort: currentReasoningEffort - })} - - - - ), - ...(modelMenuContent - ? { - menuAlign: 'end' as const, - menuClassName: 'w-64', - menuContent: modelMenuContent, - title: currentProvider - ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) - : copy.switchModel, - variant: 'menu' as const - } - : { - onSelect: () => setModelPickerOpen(true), - title: currentProvider - ? copy.providerModelTitle(currentProvider, currentModel || copy.noModel) - : copy.openModelPicker, - variant: 'action' as const - }) - }, { className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, hidden: !chatOpen, @@ -465,11 +420,6 @@ export function useStatusbarItems({ contextBar, contextUsage, copy, - currentFastMode, - currentModel, - currentProvider, - currentReasoningEffort, - modelMenuContent, sessionStartedAt, showYoloToggle, terminalTakeover, From 989d5d0cb72a23d28cb919363887fe8a60a61b5c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 23:37:38 -0500 Subject: [PATCH 2/8] fix(desktop): declutter date-pinned model snapshots in the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider catalogs surface date-pinned snapshots (`…-20251101`) that the picker rendered as standalone rows with the date baked into the name ("Opus 4 5 20251101"). Strip the trailing date from display names, and fold a snapshot out of the list when its rolling alias is present so the alias stays selectable/searchable while the exact dated id isn't shown as its own row. --- apps/desktop/src/lib/model-status-label.test.ts | 5 +++++ apps/desktop/src/lib/model-status-label.ts | 3 +++ apps/desktop/src/store/model-visibility.test.ts | 13 +++++++++++++ apps/desktop/src/store/model-visibility.ts | 5 +++++ 4 files changed, 26 insertions(+) diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index 58c03a3f12293..78fe51492b1e2 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -10,6 +10,11 @@ describe('model-status-label', () => { expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5') }) + it('strips trailing date-pin snapshots from the display name', () => { + expect(displayModelName('claude-opus-4-5-20251101')).toBe('Opus 4 5') + expect(displayModelName('anthropic/claude-haiku-4-5-20251001')).toBe('Haiku 4 5') + }) + it('maps reasoning effort to compact labels', () => { expect(reasoningEffortLabel('high')).toBe('High') expect(reasoningEffortLabel('xhigh')).toBe('Max') diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 3a7d065cf17e4..60f0e81a959ac 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -68,6 +68,9 @@ export function modelDisplayParts(model: string): { name: string; tag: string } } } + // Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name. + base = base.replace(/-\d{8}$/, '') + return { name: prettifyBase(base) || model.trim() || 'No model', tag } } diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts index ce78d1a6aa775..90eccdf457e84 100644 --- a/apps/desktop/src/store/model-visibility.test.ts +++ b/apps/desktop/src/store/model-visibility.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import type { ModelOptionProvider } from '@/types/hermes' import { + collapseModelFamilies, effectiveVisibleKeys, emptyProviderSentinelKey, isProviderSentinel, @@ -78,6 +79,18 @@ describe('model visibility', () => { expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false) }) + it('folds a date-pinned snapshot into its rolling alias when present', () => { + const families = collapseModelFamilies(['claude-opus-4-5', 'claude-opus-4-5-20251101']) + + expect(families.map(f => f.id)).toEqual(['claude-opus-4-5']) + }) + + it('keeps a date-pinned snapshot standing alone when it has no alias', () => { + const families = collapseModelFamilies(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001']) + + expect(families.map(f => f.id)).toEqual(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001']) + }) + it('sentinel key helper produces correct format', () => { expect(emptyProviderSentinelKey('openai')).toBe('openai::') expect(isProviderSentinel('openai::')).toBe(true) diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts index de694fe3af58a..5c2b568c596ca 100644 --- a/apps/desktop/src/store/model-visibility.ts +++ b/apps/desktop/src/store/model-visibility.ts @@ -51,6 +51,11 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[] continue } + if (/-\d{8}$/.test(model) && present.has(model.replace(/-\d{8}$/, ''))) { + // A date-pinned snapshot superseded by its rolling alias — drop the dupe. + continue + } + const fastId = `${model}-fast` const hasFast = present.has(fastId) families.push({ fastId: hasFast ? fastId : null, id: model }) From 0e81d2fb71c11d731189fd36e6783c4437bdba16 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 23:37:46 -0500 Subject: [PATCH 3/8] feat(desktop): per-model effort/fast presets in the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each model remembers its own reasoning effort / fast mode (localStorage, like model-visibility): editing a model's effort/fast in the submenu writes its preset, and selecting a model restores its preset onto the session (capability-gated, Hermes defaults when unset). Every row shows its own remembered settings (grayed), and the row label and edit submenu read the same effective value so they can't disagree. Presets are desktop-client state only — applyModelPreset() no-ops without a live session id, so selecting a model can't fall through to the gateway's persistent agent.reasoning_effort / agent.service_tier writes. Inactive variant `-fast` edits stay preset-only: toggleFast() records { fast } on the base model and only swaps models when the row is active, and selectFamily() honors a saved variant-fast preset by selecting the `-fast` sibling id. --- .../src/app/shell/model-edit-submenu.test.tsx | 84 +++++++++++++ .../src/app/shell/model-edit-submenu.tsx | 113 +++++++++--------- .../src/app/shell/model-menu-panel.tsx | 72 +++++++---- apps/desktop/src/store/model-presets.test.ts | 51 ++++++++ apps/desktop/src/store/model-presets.ts | 86 +++++++++++++ 5 files changed, 328 insertions(+), 78 deletions(-) create mode 100644 apps/desktop/src/app/shell/model-edit-submenu.test.tsx create mode 100644 apps/desktop/src/store/model-presets.test.ts create mode 100644 apps/desktop/src/store/model-presets.ts diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx new file mode 100644 index 0000000000000..e2493c600200e --- /dev/null +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' +import { $modelPresets, getModelPreset } from '@/store/model-presets' +import { $activeSessionId } from '@/store/session' + +import { type FastControl, ModelEditSubmenu } from './model-edit-submenu' + +// Radix calls these on open; jsdom doesn't implement them. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) + +beforeEach(() => { + $modelPresets.set({}) + $activeSessionId.set(null) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +// Render the submenu inside an open menu/sub so its content (switches) mounts. +function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise }) { + return render( + + + + edit + + + + + ) +} + +// Regression: editing the active row before a live session exists must stay +// preset-only — the gateway's config.set falls back to global config when no +// session matches, so it must not be called. (Caught in the second review.) +describe('ModelEditSubmenu no-session guard', () => { + it('param fast: records the preset but skips the gateway without a session', () => { + const requestGateway = vi.fn().mockResolvedValue({}) + renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway }) + + fireEvent.click(screen.getByRole('switch')) + + expect(getModelPreset('p1', 'm1').fast).toBe(true) + expect(requestGateway).not.toHaveBeenCalled() + }) + + it('reasoning: records the preset but skips the gateway without a session', () => { + const requestGateway = vi.fn().mockResolvedValue({}) + renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway }) + + // Thinking starts on (medium); toggling it off routes through patchReasoning. + fireEvent.click(screen.getByRole('switch')) + + expect(getModelPreset('p1', 'm1').effort).toBe('none') + expect(requestGateway).not.toHaveBeenCalled() + }) + + it('param fast: pushes to the gateway once a session is active', async () => { + const requestGateway = vi.fn().mockResolvedValue({}) + $activeSessionId.set('sess1') + renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway }) + + fireEvent.click(screen.getByRole('switch')) + + expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' }) + }) +}) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 6872cca7f5ae0..881e33cab056b 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -12,13 +12,9 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' +import { setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' -import { - $activeSessionId, - $currentReasoningEffort, - setCurrentFastMode, - setCurrentReasoningEffort -} from '@/store/session' +import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' // Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned // by the Thinking toggle, not the radio. @@ -76,96 +72,104 @@ export function resolveFastControl( } interface ModelEditSubmenuProps { + /** This row's effective reasoning effort (live for the active model, else its + * preset) — the submenu shows and edits from this, never the raw session. */ + effort: string /** How fast mode is offered for this model (param toggle vs. variant swap). */ fastControl: FastControl /** Whether this row's model is the active one. */ isActive: boolean - /** Switch to this model (resolves false on failure). Awaited before applying - * edits when not active so a failed switch doesn't write to the old model. */ - onActivate: () => Promise | void + /** This row's model id — edits persist as its global preset. */ + model: string /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ onSelectModel: (model: string) => Promise | void + /** This row's provider slug — edits persist as its global preset. */ + provider: string /** Whether this model supports reasoning effort. */ reasoning: boolean requestGateway: (method: string, params?: Record) => Promise } export function ModelEditSubmenu({ + effort, fastControl, isActive, - onActivate, + model, onSelectModel, + provider, reasoning, requestGateway }: ModelEditSubmenuProps) { const { t } = useI18n() const copy = t.shell.modelOptions - // Reactive session state comes straight from the stores rather than being - // drilled through the panel, so editing it re-renders only this submenu. const activeSessionId = useStore($activeSessionId) - const currentReasoningEffort = useStore($currentReasoningEffort) - const effort = normalizeEffort(currentReasoningEffort) - const thinkingOn = isThinkingEnabled(currentReasoningEffort) + const effortValue = normalizeEffort(effort) + const thinkingOn = isThinkingEnabled(effort) - // Reasoning/fast are session-scoped (they apply to the active model), so - // editing a non-active model first switches to it. Returns false if the - // switch failed, so callers skip applying to the wrong (previous) model. - const ensureActive = async (): Promise => { - if (isActive) { - return true + // Editing always records the model's global preset; the active model also gets + // it pushed onto the live session. Non-active edits stay preset-only — they do + // not switch you to that model. + const patchReasoning = async (next: string) => { + setModelPreset(provider, model, { effort: next }) + + if (!isActive) { + return } - return (await onActivate()) !== false - } - - const patchReasoning = async (next: string, rollback: string) => { setCurrentReasoningEffort(next) + // Preset-only without a session: `isActive` holds for the global/default + // row pre-session, and the gateway's `config.set` falls back to global + // config when none matches — so don't reach it (preset + optimistic store + // are the whole effect). Same guard in applyModelPreset / toggleFast. + if (!activeSessionId) { + return + } + try { - if (!(await ensureActive())) { - setCurrentReasoningEffort(rollback) - - return - } - - await requestGateway('config.set', { - key: 'reasoning', - session_id: activeSessionId ?? '', - value: next - }) + await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) } catch (err) { - setCurrentReasoningEffort(rollback) + setCurrentReasoningEffort(effort) + setModelPreset(provider, model, { effort }) notifyError(err, copy.updateFailed) } } const toggleFast = (enabled: boolean) => { if (fastControl.kind === 'variant') { - // Fast is a separate model id — swap to it (or back to the base). - void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) + // Fast is a separate model id. Record the choice on the base model's + // preset (selectFamily picks the `-fast` sibling later when set), and + // only swap models now if this is the active row — inactive edits must + // stay preset-only, same as the param path below. + setModelPreset(provider, fastControl.baseId, { fast: enabled }) + + if (isActive) { + void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) + } return } if (fastControl.kind === 'param') { + setModelPreset(provider, model, { fast: enabled }) + + if (!isActive) { + return + } + setCurrentFastMode(enabled) + // Preset-only without a session (see patchReasoning). + if (!activeSessionId) { + return + } void (async () => { try { - if (!(await ensureActive())) { - setCurrentFastMode(!enabled) - - return - } - - await requestGateway('config.set', { - key: 'fast', - session_id: activeSessionId ?? '', - value: enabled ? 'fast' : 'normal' - }) + await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' }) } catch (err) { setCurrentFastMode(!enabled) + setModelPreset(provider, model, { fast: !enabled }) notifyError(err, copy.fastFailed) } })() @@ -188,9 +192,7 @@ export function ModelEditSubmenu({ - void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort) - } + onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')} size="xs" /> @@ -205,10 +207,7 @@ export function ModelEditSubmenu({ <> {copy.effort} - void patchReasoning(value, currentReasoningEffort)} - value={effort} - > + void patchReasoning(value)} value={effortValue}> {EFFORT_OPTIONS.map(option => ( effectiveVisibleKeys(visibleModels, providers ?? []), [visibleModels, providers] @@ -95,6 +98,31 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const switchTo = (model: string, provider: string) => onSelectModel({ model, persistGlobal: !activeSessionId, provider }) + // Selecting a model row restores that model's remembered preset onto the + // session (effort/fast), gated by capability. Unset → Hermes defaults. + const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { + const caps = provider.capabilities?.[family.id] + const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {} + + // Variant-fast models (no speed param) express "fast" as a separate `-fast` + // id, so honor the saved preset by selecting that sibling. Param-fast is + // applied via applyModelPreset below instead. + const variantFast = !(caps?.fast ?? false) && !!family.fastId + const targetId = variantFast && preset.fast === true ? family.fastId! : family.id + + if ((await switchTo(targetId, provider.slug)) === false) { + return + } + + await applyModelPreset( + { + effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined, + fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined + }, + { failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId } + ) + } + const groups = useMemo( () => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), [providers, search, optionsModel, optionsProvider, effectiveVisibleModels] @@ -152,36 +180,36 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model // -fast variant carries the same param support as its base. const caps = group.provider.capabilities?.[family.id] - // Single source of truth for the active row's fast state — keeps - // the row label in lock-step with the submenu's Fast toggle and - // handles the standalone `-fast` id case. + // Effective settings for this row: live session state when it's + // the active model, otherwise its remembered preset (Hermes + // defaults when unset). Row label AND submenu read from these so + // they never disagree. + const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {} + const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? '' + const effFast = isCurrent ? currentFastMode : preset.fast ?? false + const fastControl = resolveFastControl( activeId ?? family.id, group.provider.models ?? [], caps?.fast ?? false, - currentFastMode + effFast ) - // Grayed text is live session state only. Do not label inactive - // rows as "Fast" just because they have a fast-capable sibling: - // that makes an off Fast toggle look like it is already on. - const meta = isCurrent - ? [ - fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - reasoningEffortLabel(currentReasoningEffort) || copy.medium - ] - .filter(Boolean) - .join(' ') - : '' + const meta = [ + fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, + (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null + ] + .filter(Boolean) + .join(' ') // Every row is a hover-Edit submenu trigger. Activating it - // (pointer or keyboard) switches to the family's base model; - // the Fast toggle inside swaps to the -fast sibling (or flips - // the speed param). The sub-trigger has no `onSelect`, so wire - // both click and Enter/Space for keyboard parity. + // (pointer or keyboard) switches to the family's base model and + // restores its preset; the Fast toggle inside swaps to the -fast + // sibling (or flips the speed param). The sub-trigger has no + // `onSelect`, so wire both click and Enter/Space for keyboard parity. const activate = () => { if (!isCurrent) { - void switchTo(family.id, group.provider.slug) + void selectFamily(family, group.provider) } } @@ -204,10 +232,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model {isCurrent ? : null} switchTo(family.id, group.provider.slug)} + model={family.id} onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)} + provider={group.provider.slug} reasoning={caps?.reasoning ?? true} requestGateway={requestGateway} /> diff --git a/apps/desktop/src/store/model-presets.test.ts b/apps/desktop/src/store/model-presets.test.ts new file mode 100644 index 0000000000000..efe49ffa6e53f --- /dev/null +++ b/apps/desktop/src/store/model-presets.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets' + +describe('model presets', () => { + beforeEach(() => $modelPresets.set({})) + + it('round-trips a preset and merges patches without dropping prior fields', () => { + setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' }) + setModelPreset('anthropic', 'claude-opus-4-8', { fast: true }) + + expect(getModelPreset('anthropic', 'claude-opus-4-8')).toEqual({ effort: 'high', fast: true }) + }) + + it('returns an empty preset for unknown models', () => { + expect(getModelPreset('x', 'y')).toEqual({}) + }) + + it('keys by provider::model', () => { + expect(modelPresetKey('openai', 'gpt-5.5')).toBe('openai::gpt-5.5') + }) + + it('pushes only the provided dimensions to the gateway', async () => { + const calls: { method: string; params?: Record }[] = [] + + const request = async (method: string, params?: Record) => { + calls.push({ method, params }) + + return {} as T + } + + await applyModelPreset({ effort: 'high' }, { failMessage: 'x', request, sessionId: 's1' }) + await applyModelPreset({}, { failMessage: 'x', request, sessionId: 's1' }) + + expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }]) + }) + + it('no-ops without a session so selecting a model cannot mutate global config', async () => { + const calls: { method: string; params?: Record }[] = [] + + const request = async (method: string, params?: Record) => { + calls.push({ method, params }) + + return {} as T + } + + await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null }) + + expect(calls).toEqual([]) + }) +}) diff --git a/apps/desktop/src/store/model-presets.ts b/apps/desktop/src/store/model-presets.ts new file mode 100644 index 0000000000000..9a66a8b0d2ce7 --- /dev/null +++ b/apps/desktop/src/store/model-presets.ts @@ -0,0 +1,86 @@ +import { atom } from 'nanostores' + +import { persistString, storedString } from '@/lib/storage' + +import { notifyError } from './notifications' +import { setCurrentFastMode, setCurrentReasoningEffort } from './session' + +const STORAGE_KEY = 'hermes.desktop.model-presets' + +/** Per-model reasoning/fast preset, remembered globally across sessions and + * re-applied to the session whenever that model is selected. Unset dimensions + * fall back to the Hermes default (medium effort, no fast). */ +export interface ModelPreset { + effort?: string + fast?: boolean +} + +type RequestGateway = (method: string, params?: Record) => Promise + +/** Stable `provider::model` key (matches the visibility-store format). */ +export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}` + +function load(): Record { + const raw = storedString(STORAGE_KEY) + + if (!raw) { + return {} + } + + try { + const parsed = JSON.parse(raw) + + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} + } catch { + return {} + } +} + +export const $modelPresets = atom>(load()) + +export function getModelPreset(provider: string, model: string): ModelPreset { + return $modelPresets.get()[modelPresetKey(provider, model)] ?? {} +} + +/** Merge a partial preset for one model and persist. */ +export function setModelPreset(provider: string, model: string, patch: ModelPreset): void { + const key = modelPresetKey(provider, model) + const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } } + + $modelPresets.set(next) + persistString(STORAGE_KEY, JSON.stringify(next)) +} + +/** Push a model's preset onto the active session (optimistic + gateway). + * `undefined` skips that dimension; values are capability-gated upstream. + * No-ops without a session — the gateway's `config.set` reasoning/fast fall + * back to persistent (global/profile) config when none matches, so selecting + * a model must not reach it (else it rewrites `agent.*`, defaults included). */ +export async function applyModelPreset( + { effort, fast }: ModelPreset, + ctx: { failMessage: string; request: RequestGateway; sessionId: null | string } +): Promise { + if (!ctx.sessionId) { + return + } + + if (effort !== undefined) { + setCurrentReasoningEffort(effort) + } + + if (fast !== undefined) { + setCurrentFastMode(fast) + } + + try { + if (effort !== undefined) { + await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort }) + } + + if (fast !== undefined) { + await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' }) + } + } catch (err) { + notifyError(err, ctx.failMessage) + } +} From a0ec4f52b948104cc91fb291153edb3a5bf6b52e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 23:37:53 -0500 Subject: [PATCH 4/8] feat(desktop): disconnect external (CLI-managed) providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External providers (Claude Code) store creds outside Hermes, so the disconnect API refuses them. The backend now hands the GUI a per-OS `disconnect_command` that clears the credential the same way the CLI's logout does (macOS Keychain entry + ~/.claude/.credentials.json), and the misleading "use claude setup-token" hint is corrected. Settings → Providers offers a Disconnect button for these: it confirms, leaves Settings, and runs the removal command in the embedded terminal via a new runInTerminal() (queues onto $terminalInjection; the terminal pane flushes and clears it once its session is live). The expanded list also gets its own "Other providers" header so it no longer reads as grouped under "Connected". API-managed providers keep the one-click (trash) disconnect. --- apps/desktop/src/app/right-sidebar/store.ts | 19 +++++ .../terminal/use-terminal-session.ts | 24 ++++++ apps/desktop/src/app/settings/index.tsx | 2 +- .../app/settings/providers-settings.test.tsx | 4 +- .../src/app/settings/providers-settings.tsx | 82 ++++++++++++++++--- apps/desktop/src/i18n/en.ts | 7 +- apps/desktop/src/i18n/ja.ts | 1 - apps/desktop/src/i18n/types.ts | 6 +- apps/desktop/src/i18n/zh-hant.ts | 1 - apps/desktop/src/i18n/zh.ts | 6 +- apps/desktop/src/types/hermes.ts | 3 + hermes_cli/web_server.py | 34 +++++++- tests/hermes_cli/test_web_oauth_dispatch.py | 10 ++- 13 files changed, 178 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/store.ts b/apps/desktop/src/app/right-sidebar/store.ts index 8c07f0824506e..b0e26f038862a 100644 --- a/apps/desktop/src/app/right-sidebar/store.ts +++ b/apps/desktop/src/app/right-sidebar/store.ts @@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false)) $terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active)) export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active) + +/** A command queued to run in the embedded terminal. The terminal pane flushes + * (and clears) it once its session is live, so a value set before the pane + * mounts still runs. Cleared after flush so a later remount can't replay it. */ +export const $terminalInjection = atom(null) + +/** Open the terminal pane and run a command in it. Used to disconnect external + * (CLI-managed) providers, which Hermes can't clear via the API — the user + * sees exactly what runs instead of Hermes silently deleting their creds. */ +export const runInTerminal = (command: string) => { + const trimmed = command.trim() + + if (!trimmed) { + return + } + + setTerminalTakeover(true) + $terminalInjection.set(trimmed) +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 1e5d4d275b726..3479ed6db2f82 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics' import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { useTheme } from '@/themes/context' +import { $terminalInjection } from '../store' + import { makeTerminalReader, setActiveTerminalReader } from './buffer' import { isAddSelectionShortcut, @@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes return () => cancelAnimationFrame(raf) }, [activeTheme, themeName]) + // Flush a queued command (e.g. a provider-disconnect) into the live session. + // Only active while open; the subscribe fires immediately, so a command set + // before this pane mounted runs as soon as the session is ready. Clearing the + // atom after writing stops a later remount from replaying a stale command. + useEffect(() => { + if (status !== 'open') { + return + } + + return $terminalInjection.subscribe(command => { + const id = sessionIdRef.current + + if (!command || !id) { + return + } + + void window.hermesDesktop?.terminal?.write(id, `${command}\r`) + $terminalInjection.set(null) + termRef.current?.focus() + }) + }, [status]) + return { addSelectionToChat, hostRef, diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 6c832799eb239..ecf0f29377d80 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang onMainModelChanged={onMainModelChanged} /> ) : activeView === 'providers' ? ( - + ) : activeView === 'keys' ? ( ) : activeView === 'mcp' ? ( diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index 8379d203f6c1a..27c029b442c0e 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -55,7 +55,7 @@ afterEach(() => { async function renderProvidersSettings() { const { ProvidersSettings } = await import('./providers-settings') - return render() + return render() } describe('ProvidersSettings', () => { @@ -95,6 +95,6 @@ describe('ProvidersSettings', () => { expect(await screen.findByText('Qwen Code')).toBeTruthy() expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull() - expect(screen.getByText(/managed outside Hermes/)).toBeTruthy() + expect(screen.getByText(/managed by its own CLI/)).toBeTruthy() }) }) diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index f1132e6c33d3f..2585e13995d02 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' +import { runInTerminal } from '@/app/right-sidebar/store' import { FEATURED_ID, FeaturedProviderRow, @@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' +// The embedded terminal (and thus the "run disconnect command" path) only +// exists in the Electron desktop shell, not the web dashboard. +const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal) + +// Parallel group headers ("Connected", "Other providers") so the expanded list +// reads as its own section instead of bleeding into the connected group. +function GroupLabel({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ) +} + // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys'] as const @@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr function OAuthPicker({ disconnecting, onDisconnect, + onTerminalDisconnect, onWantApiKey, providers }: { disconnecting: null | string onDisconnect: (provider: OAuthProvider) => void + onTerminalDisconnect: (provider: OAuthProvider) => void onWantApiKey: () => void providers: OAuthProvider[] }) { @@ -138,15 +156,14 @@ function OAuthPicker({ {featured && } {connected.length > 0 && ( <> -

- {p.connected} -

+ {p.connected} {connected.map(p => ( ))} @@ -154,6 +171,7 @@ function OAuthPicker({ )} {showOthers && ( <> + {connected.length > 0 && {p.otherProviders}} {others.map(p => ( ))} @@ -180,21 +198,26 @@ function ConnectedProviderRow({ disconnecting, onDisconnect, onSelect, + onTerminalDisconnect, provider }: { disconnecting: boolean onDisconnect: (provider: OAuthProvider) => void onSelect: (provider: OAuthProvider) => void + onTerminalDisconnect: (provider: OAuthProvider) => void provider: OAuthProvider }) { const { t } = useI18n() + const copy = t.settings.providers const title = providerTitle(provider) const Trail = provider.flow === 'external' ? Terminal : ChevronRight + // Hermes can clear this provider's creds via the API. const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' - - const disconnectHint = provider.flow === 'external' - ? t.settings.providers.removeExternal(title, provider.cli_command) - : t.settings.providers.removeKeyManaged(title) + // External (CLI-managed) provider Hermes can't clear via the API, but ships a + // command we can run in the embedded terminal (Electron shell only). + const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal() + // Only fall back to a static "remove it elsewhere" hint when we offer no button. + const showHint = !canDisconnect && !terminalDisconnect return (
@@ -203,13 +226,13 @@ function ConnectedProviderRow({ {title} - {t.settings.providers.connected} + {copy.connected}

{t.onboarding.flowSubtitles[provider.flow]}

- {!canDisconnect && ( + {showHint && (

- {disconnectHint} + {provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}

)} @@ -228,6 +251,18 @@ function ConnectedProviderRow({ {disconnecting ? : } )} + {terminalDisconnect && ( + + )}
) @@ -243,7 +278,7 @@ function NoProviderKeys() { ) } -export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { +export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) { const { t } = useI18n() const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) @@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return () => void (cancelled = true) }, [onboardingActive]) + // External (CLI-managed) providers can't be cleared via the API by design — + // Hermes never deletes creds another tool owns behind a silent API call. + // Instead we run the documented removal command in the embedded terminal so + // the user sees exactly what executes, then return them to chat to watch it. + function handleTerminalDisconnect(provider: OAuthProvider) { + const command = provider.disconnect_command + + if (!command) { + return + } + + const name = providerTitle(provider) + + if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) { + return + } + + // Leave the settings overlay so the terminal pane (chat-only) is visible. + onClose() + runInTerminal(command) + notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) }) + } + async function handleDisconnect(provider: OAuthProvider) { const name = providerTitle(provider) @@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps void handleDisconnect(provider)} + onTerminalDisconnect={handleTerminalDisconnect} onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} /> @@ -359,6 +418,7 @@ interface ProviderKeyGroup { } interface ProvidersSettingsProps { + onClose: () => void onViewChange: (view: ProviderView) => void view: ProviderView } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 44c738da1b3fc..2710f8273f681 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -565,9 +565,14 @@ export const en: Translations = { collapse: 'Collapse', connectAnother: 'Connect another provider', otherProviders: 'Other providers', + disconnect: 'Disconnect', + disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)', removeConfirm: provider => `Remove ${provider}?`, - removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`, + removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`, removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`, + removeTerminalConfirm: (provider, command) => + `Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`, + removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`, removedTitle: 'Account removed', removedMessage: provider => `${provider} was removed.`, failedRemove: provider => `Could not remove ${provider}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index b3719272a99f4..4f56ed46b657f 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -695,7 +695,6 @@ export const ja = defineLocale({ connectAnother: '別のプロバイダーを接続', otherProviders: 'その他のプロバイダー', removeConfirm: provider => `${provider} を削除しますか?`, - removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`, removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`, removedTitle: 'アカウントを削除しました', removedMessage: provider => `${provider} を削除しました。`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d93769bbc59d6..58d78d4a384f7 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -447,9 +447,13 @@ export interface Translations { collapse: string connectAnother: string otherProviders: string + disconnect: string + disconnectInTerminal: string removeConfirm: (provider: string) => string - removeExternal: (provider: string, command: string) => string + removeExternalGeneric: (provider: string) => string removeKeyManaged: (provider: string) => string + removeTerminalConfirm: (provider: string, command: string) => string + removeTerminalRunning: (provider: string) => string removedTitle: string removedMessage: (provider: string) => string failedRemove: (provider: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index a6607c53416ba..f01c94de7384d 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -672,7 +672,6 @@ export const zhHant = defineLocale({ connectAnother: '連結其他提供方', otherProviders: '其他提供方', removeConfirm: provider => `移除 ${provider}?`, - removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`, removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`, removedTitle: '帳號已移除', removedMessage: provider => `${provider} 已移除。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 2f3d22230a27f..ea24026a5b1ad 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -759,9 +759,13 @@ export const zh: Translations = { collapse: '收起', connectAnother: '连接其他提供方', otherProviders: '其他提供方', + disconnect: '断开连接', + disconnectInTerminal: '断开连接(在终端中运行移除命令)', removeConfirm: provider => `移除 ${provider}?`, - removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`, + removeExternalGeneric: provider => `${provider} 由其自身的 CLI 管理 — 请在那里移除。`, removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`, + removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`, + removeTerminalRunning: provider => `正在终端中断开 ${provider}…`, removedTitle: '账号已移除', removedMessage: provider => `${provider} 已移除。`, failedRemove: provider => `无法移除 ${provider}`, diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 627fe5e53e1f8..55019fb0827c1 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -47,6 +47,9 @@ export interface OAuthProviderStatus { export interface OAuthProvider { cli_command: string + /** Shell command that clears an external provider's credentials, run in the + * embedded terminal. Null when Hermes doesn't know how to remove it. */ + disconnect_command?: null | string disconnect_hint?: null | string disconnectable?: boolean docs_url: string diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a75a646835274..14e2a8a5ecc33 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5228,10 +5228,39 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: return {"logged_in": False} +def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]: + """Shell command that clears an external provider's credentials. + + External providers store their credentials outside Hermes, so the disconnect + API deliberately refuses them (we never delete files another CLI owns on the + user's behalf via a silent API call). For the ones we know how to clear we + instead hand the GUI a command it can *run in the embedded terminal* — the + user sees exactly what executes, and Hermes then stops resolving the token. + + Claude Code has no scriptable logout (only the interactive ``/logout``), so + we remove the credential the same way logout does: the macOS Keychain entry + (``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json`` + file — the two sources ``read_claude_code_credentials()`` consults. Returns + None for providers we can't safely clear (the GUI shows a manual hint). + """ + if provider.get("flow") != "external": + return None + if provider.get("id") == "claude-code": + rm_file = "rm -f ~/.claude/.credentials.json" + if sys.platform == "darwin": + return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}' + return rm_file + return None + + def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]: """Return the manual disconnect path when the API cannot clear this provider.""" if provider.get("flow") == "external": - return f"Use `{provider['cli_command']}` or that provider's CLI to remove it." + if _oauth_provider_disconnect_command(provider): + # The GUI offers a one-click "run in terminal" path; this hint is the + # fallback wording for surfaces that only show text. + return "Managed outside Hermes — run the disconnect command to remove it." + return "Managed by that provider's CLI; remove it there." if status.get("source") == "env_var": return "Remove the API key from Settings → Keys instead." return None @@ -5246,6 +5275,8 @@ async def list_oauth_providers(profile: Optional[str] = None): name human label flow "pkce" | "device_code" | "external" | "loopback" cli_command fallback CLI command for users to run manually + disconnect_command shell command that clears an external provider's + creds (run in the embedded terminal), else null docs_url external docs/portal link for the "Learn more" link status: logged_in bool — currently has usable creds @@ -5267,6 +5298,7 @@ async def list_oauth_providers(profile: Optional[str] = None): "cli_command": p["cli_command"], "docs_url": p["docs_url"], "disconnect_hint": disconnect_hint, + "disconnect_command": _oauth_provider_disconnect_command(p), "disconnectable": disconnect_hint is None, "status": status, }) diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 9b1b853c93c8a..1d87573fe5882 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -476,13 +476,21 @@ def test_oauth_catalog_marks_external_providers_not_disconnectable(): assert resp.status_code == 200, resp.text providers = {p["id"]: p for p in resp.json()["providers"]} + # Qwen: external and not auto-removable, and we don't know a clear command, + # so it stays a manual hint with no runnable disconnect command. assert providers["qwen-oauth"]["flow"] == "external" assert providers["qwen-oauth"]["disconnectable"] is False assert "provider's CLI" in providers["qwen-oauth"]["disconnect_hint"] + assert providers["qwen-oauth"]["disconnect_command"] is None + # Claude Code: still not API-disconnectable, but we hand the GUI a runnable + # command (clears the keychain entry / credentials file) so it can offer a + # one-click "run in terminal" disconnect. assert providers["claude-code"]["flow"] == "external" assert providers["claude-code"]["disconnectable"] is False - assert "provider's CLI" in providers["claude-code"]["disconnect_hint"] + assert providers["claude-code"]["disconnect_hint"] + cmd = providers["claude-code"]["disconnect_command"] + assert cmd and ".claude/.credentials.json" in cmd def test_external_oauth_disconnect_rejected_before_auth_mutation(monkeypatch): From dd0e3e0a052ae2b0804015516e9ba7a3cba979b9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 23:37:58 -0500 Subject: [PATCH 5/8] fix(desktop): tighten thread content top padding --- apps/desktop/src/components/assistant-ui/thread-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/components/assistant-ui/thread-list.tsx b/apps/desktop/src/components/assistant-ui/thread-list.tsx index 0a4be83961a64..e3faf64547fdd 100644 --- a/apps/desktop/src/components/assistant-ui/thread-list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-list.tsx @@ -140,7 +140,7 @@ const ThreadMessageListInner: FC = ({ ? 'pt-[calc(var(--titlebar-height)+0.75rem)]' : isSecondaryWindow() ? 'pt-6' - : 'pt-[calc(var(--titlebar-height)+1.5rem)]' + : 'pt-[calc(var(--titlebar-height)-0.5rem)]' useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom]) useEffect(() => () => resetThreadScroll(), []) From cb6b4127e795e55bdd7ae4fe35a0ff3cd9f53736 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 16 Jun 2026 09:50:07 -0500 Subject: [PATCH 6/8] refactor(desktop): make composer model picker sticky session state The picker no longer touches the profile default. Model/effort/fast live as plain UI state persisted in localStorage, so a pick follows across Cmd+N and restarts instead of snapping back. New chats ship that state through session.create as per-session overrides; live chats still scope switches to the current session. Settings -> Model remains the only surface that writes the profile default. The gateway now accepts those session.create overrides, builds the agent with them directly, reflects them in the immediate session.info payload, and writes the chat's own model_config into the lazy DB row so reconnect/resume restores that chat instead of the global default. --- apps/desktop/src/app/desktop-controller.tsx | 4 +- .../session/hooks/use-model-controls.test.tsx | 42 ++++- .../app/session/hooks/use-model-controls.ts | 72 ++++---- .../app/session/hooks/use-session-actions.ts | 30 +++- .../src/app/shell/model-menu-panel.tsx | 8 +- apps/desktop/src/components/model-picker.tsx | 35 +--- apps/desktop/src/i18n/en.ts | 2 - apps/desktop/src/i18n/ja.ts | 2 - apps/desktop/src/i18n/types.ts | 2 - apps/desktop/src/i18n/zh-hant.ts | 2 - apps/desktop/src/i18n/zh.ts | 2 - apps/desktop/src/store/session.ts | 46 ++++- apps/desktop/src/store/updates.test.ts | 8 + tests/test_tui_gateway_server.py | 167 +++++++++++++++++- tui_gateway/server.py | 79 ++++++++- website/docs/user-guide/desktop.md | 9 +- 16 files changed, 405 insertions(+), 105 deletions(-) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index e071a2a0ce603..45251ceef9b5c 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -711,7 +711,9 @@ export function DesktopController() { } lastGatewayProfileRef.current = activeGatewayProfile - void refreshCurrentModel() + // Force: the new profile has its own default, so reseed even if the composer + // already shows the previous profile's model. + void refreshCurrentModel(true) void refreshActiveProfile() }, [activeGatewayProfile, refreshCurrentModel]) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 612290800e065..f7765de04c595 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -130,7 +130,6 @@ describe('useModelControls', () => { await expect( controls.selectModel({ model: 'claude-sonnet-4.6', - persistGlobal: false, provider: 'anthropic' }) ).resolves.toBe(true) @@ -143,26 +142,57 @@ describe('useModelControls', () => { expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) }) - it('keeps the global path on setGlobalModel when there is no active session', async () => { - setGlobalModel.mockResolvedValue(undefined) + it('stores a no-session pick as UI state with no gateway or global write', async () => { + const requestGateway = vi.fn() let controls!: Controls render( (controls = value)} - requestGateway={vi.fn()} + requestGateway={requestGateway} /> ) await expect( controls.selectModel({ model: 'claude-sonnet-4.6', - persistGlobal: false, provider: 'anthropic' }) ).resolves.toBe(true) - expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6') + // The pick is plain UI state; session.create ships it later. Nothing touches + // the gateway or the profile default here. + expect($currentModel.get()).toBe('claude-sonnet-4.6') + expect($currentProvider.get()).toBe('anthropic') + expect(requestGateway).not.toHaveBeenCalled() + expect(setGlobalModel).not.toHaveBeenCalled() + }) + + it('seeds an empty composer model from global but never clobbers a pick', async () => { + vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + // Empty → seeds the default. + await result.current.refreshCurrentModel() + expect($currentModel.get()).toBe('openai/gpt-5.5') + + // A user pick must survive the lifecycle refreshes that fire on boot / fresh + // draft / session events. + setCurrentModel('anthropic/claude-sonnet-4.6') + setCurrentProvider('anthropic') + await result.current.refreshCurrentModel() + expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6') + + // A profile swap forces a reseed to the new profile's default. + await result.current.refreshCurrentModel(true) + expect($currentModel.get()).toBe('openai/gpt-5.5') }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 681eac871a21f..50788b1e0befe 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -1,7 +1,7 @@ import { type QueryClient } from '@tanstack/react-query' import { useCallback } from 'react' -import { getGlobalModelInfo, setGlobalModel } from '@/hermes' +import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' import { notifyError } from '@/store/notifications' import { @@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes' interface ModelSelection { model: string - persistGlobal: boolean provider: string } @@ -28,6 +27,7 @@ interface ModelControlsOptions { export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) { const { t } = useI18n() const copy = t.desktop + const updateModelOptionsCache = useCallback( (provider: string, model: string, includeGlobal: boolean) => { const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model }) @@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway [activeSessionId, queryClient] ) - const refreshCurrentModel = useCallback(async () => { + // Seed the composer's model state from the profile default. `force` reseeds + // for a profile swap (the new profile has its own default); otherwise this + // only fills an EMPTY selection so a user's pick (plain UI state in + // $currentModel) survives the lifecycle refreshes that fire on boot / fresh + // draft / session events. A live session owns the footer, so skip entirely. + const refreshCurrentModel = useCallback(async (force = false) => { try { + if ($activeSessionId.get()) { + return + } + + if (!force && $currentModel.get()) { + return + } + const result = await getGlobalModelInfo() - // A resumed/live session owns the footer model state. Global config - // refreshes (gateway boot, profile swap, settings save) must not clobber - // the active chat's runtime model/provider in the status bar. - if ($activeSessionId.get()) { + if ($activeSessionId.get() || (!force && $currentModel.get())) { return } @@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway } }, []) - // Returns whether the switch succeeded so callers can await it before - // applying follow-up changes (e.g. editing a model's reasoning/fast must land - // on the right active model — bail rather than write to the previous one). + // Returns whether the switch succeeded so callers can await it before applying + // follow-up changes. The composer model is plain UI state: with no live + // session it's just stored (and shipped on the next session.create); with one + // it's scoped to that session via config.set. It NEVER writes the profile + // default — that lives in Settings → Model — so picking a model here can't + // silently mutate global config. const selectModel = useCallback( async (selection: ModelSelection): Promise => { - const includeGlobal = selection.persistGlobal || !activeSessionId // Snapshot for rollback: the switch is applied optimistically, so a // failure must restore the prior model/provider (store + query cache) // rather than leave the UI showing a model the backend never selected. @@ -78,42 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway setCurrentModel(selection.model) setCurrentProvider(selection.provider) - updateModelOptionsCache(selection.provider, selection.model, includeGlobal) + updateModelOptionsCache(selection.provider, selection.model, !activeSessionId) + + // No live session yet: the pick is pure UI state. session.create reads + // $currentModel/$currentProvider and applies it as that session's override. + if (!activeSessionId) { + return true + } try { - if (activeSessionId) { - await requestGateway('config.set', { - session_id: activeSessionId, - key: 'model', - value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` - }) + await requestGateway('config.set', { + session_id: activeSessionId, + key: 'model', + value: `${selection.model} --provider ${selection.provider}` + }) - if (selection.persistGlobal) { - void refreshCurrentModel() - } - - void queryClient.invalidateQueries({ - queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId] - }) - - return true - } - - await setGlobalModel(selection.provider, selection.model) - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] }) return true } catch (err) { setCurrentModel(prevModel) setCurrentProvider(prevProvider) - updateModelOptionsCache(prevProvider, prevModel, includeGlobal) + updateModelOptionsCache(prevProvider, prevModel, !activeSessionId) notifyError(err, copy.modelSwitchFailed) return false } }, - [activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache] + [activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache] ) return { refreshCurrentModel, selectModel, updateModelOptionsCache } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 50b6bb0d27083..6f7a779e8ea5a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $currentCwd, + $currentFastMode, + $currentModel, + $currentProvider, + $currentReasoningEffort, $messages, $sessions, $yoloActive, @@ -407,13 +411,13 @@ export function useSessionActions({ }) setSessionStartedAt(null) setTurnStartedAt(null) - // New chats start in the configured default project dir when set, - // otherwise the sticky last-used workspace (PR #37586). - setCurrentModel('') - setCurrentProvider('') - setCurrentReasoningEffort('') + // The composer's model/effort/fast is sticky UI state (persisted in + // localStorage) — a new chat FOLLOWS your last pick instead of snapping + // back to the profile default, so we deliberately don't reset it here. The + // profile default still owns first-run seeding and profile switches (see + // refreshCurrentModel). Only $currentServiceTier (a live-session mirror) + // is cleared. setCurrentServiceTier('') - setCurrentFastMode(false) setYoloActive(false) setCurrentCwd(workspaceCwdForNewSession()) setCurrentBranch('') @@ -443,11 +447,23 @@ export function useSessionActions({ const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) await ensureGatewayProfile(newChatProfile) const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() + // The composer's model/effort/fast is sticky UI state ($currentModel, + // $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it + // with every session.create so the new chat opens on whatever the picker + // shows — applied as per-session overrides, never written to the profile + // default (that lives in Settings → Model). + const uiModel = $currentModel.get().trim() + const uiProvider = $currentProvider.get().trim() + const uiEffort = $currentReasoningEffort.get().trim() + const uiFast = $currentFastMode.get() const created = await requestGateway('session.create', { cols: 96, ...(cwd && { cwd }), - ...(newChatProfile ? { profile: newChatProfile } : {}) + ...(newChatProfile ? { profile: newChatProfile } : {}), + ...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}), + ...(uiEffort ? { reasoning_effort: uiEffort } : {}), + ...(uiFast ? { fast: true } : {}) }) const stored = created.stored_session_id ?? null diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index c0c6936175e4c..b87b1a030d16e 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -43,7 +43,7 @@ import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' interface ModelMenuPanelProps { gateway?: HermesGateway - onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise | void + onSelectModel: (selection: { model: string; provider: string }) => Promise | void requestGateway: (method: string, params?: Record) => Promise } @@ -95,8 +95,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model [visibleModels, providers] ) - const switchTo = (model: string, provider: string) => - onSelectModel({ model, persistGlobal: !activeSessionId, provider }) + // The composer picker never persists the profile default. With a session it + // scopes the switch to that session; with none it's UI state shipped on the + // next session.create (see selectModel). The default lives in Settings → Model. + const switchTo = (model: string, provider: string) => onSelectModel({ model, provider }) // Selecting a model row restores that model's remembered preset onto the // session (effort/fast), gated by capability. Unset → Hermes defaults. diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index d65bf7f89a705..be941e23d06c3 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -11,7 +11,6 @@ import { startManualOnboarding } from '../store/onboarding' import { InlineNotice } from './notifications' import { Button } from './ui/button' -import { Checkbox } from './ui/checkbox' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog' import { Skeleton } from './ui/skeleton' @@ -23,7 +22,7 @@ interface ModelPickerDialogProps { sessionId?: string | null currentModel: string currentProvider: string - onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void + onSelect: (selection: { provider: string; model: string }) => void /** * Optional class to apply to DialogContent. Use to override z-index when * stacking the picker on top of another fixed overlay (e.g. the desktop @@ -45,7 +44,6 @@ export function ModelPickerDialog({ }: ModelPickerDialogProps) { const { t } = useI18n() const copy = t.modelPicker - const [persistGlobal, setPersistGlobal] = useState(!sessionId) // Own the search term so we can filter manually. cmdk's built-in // shouldFilter reorders items by its fuzzy-match score (≈alphabetical with // an empty query), which destroys the backend's curated order. We disable @@ -79,11 +77,7 @@ export function ModelPickerDialog({ : null const selectModel = (provider: ModelOptionProvider, model: string) => { - onSelect({ - provider: provider.slug, - model, - persistGlobal: persistGlobal || !sessionId - }) + onSelect({ provider: provider.slug, model }) onOpenChange(false) } @@ -128,24 +122,13 @@ export function ModelPickerDialog({ - - - -
- - -
+ + + diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2710f8273f681..c1fbf90bcb74f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1503,8 +1503,6 @@ export const en: Translations = { unknown: '(unknown)', search: 'Filter providers and models...', noModels: 'No models found.', - persistGlobalSession: 'Persist globally (otherwise this session only)', - persistGlobal: 'Persist globally', addProvider: 'Add provider', loadFailed: 'Could not load models', noAuthenticatedProviders: 'No authenticated providers.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4f56ed46b657f..f26508e589762 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1637,8 +1637,6 @@ export const ja = defineLocale({ unknown: '(不明)', search: 'プロバイダーとモデルをフィルター...', noModels: 'モデルが見つかりません。', - persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)', - persistGlobal: 'グローバルに保持', addProvider: 'プロバイダーを追加', loadFailed: 'モデルを読み込めませんでした', noAuthenticatedProviders: '認証済みプロバイダーがありません。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 58d78d4a384f7..cc76b30d3463a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1145,8 +1145,6 @@ export interface Translations { unknown: string search: string noModels: string - persistGlobalSession: string - persistGlobal: string addProvider: string loadFailed: string noAuthenticatedProviders: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index f01c94de7384d..6f964c071f21e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1581,8 +1581,6 @@ export const zhHant = defineLocale({ unknown: '(未知)', search: '篩選提供方和模型...', noModels: '找不到模型。', - persistGlobalSession: '全域儲存(否則僅限此工作階段)', - persistGlobal: '全域儲存', addProvider: '新增提供方', loadFailed: '無法載入模型', noAuthenticatedProviders: '沒有已驗證的提供方。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index ea24026a5b1ad..0387a6be5bc94 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1683,8 +1683,6 @@ export const zh: Translations = { unknown: '(未知)', search: '筛选提供方和模型...', noModels: '未找到模型。', - persistGlobalSession: '全局保存 (否则仅当前会话)', - persistGlobal: '全局保存', addProvider: '添加提供方', loadFailed: '无法加载模型', noAuthenticatedProviders: '没有已认证的提供方。', diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index f1e1e2ee617d8..e40484cfec1f6 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -4,13 +4,23 @@ import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading' import type { ContextSuggestion } from '@/app/types' import type { HermesConnection } from '@/global' import type { ChatMessage } from '@/lib/chat-messages' -import { persistString, storedString } from '@/lib/storage' +import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' import type { SessionInfo, UsageStats } from '@/types/hermes' type Updater = T | ((current: T) => T) const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' +// The composer's model/effort/fast is sticky UI state, NOT the profile default +// (that lives in Settings → Model). Persisting it in localStorage makes a pick +// follow across Cmd+N and app restarts instead of snapping back to the default. +// It's deliberately global (not per-profile): a profile switch force-reseeds to +// that profile's default, while within a profile new chats keep your last pick. +const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model' +const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider' +const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort' +const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast' + let configuredDefaultProjectDir = '' function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string { @@ -208,11 +218,11 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI export const $freshDraftReady = atom(false) export const $busy = atom(false) export const $awaitingResponse = atom(false) -export const $currentModel = atom('') -export const $currentProvider = atom('') -export const $currentReasoningEffort = atom('') +export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '') +export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '') +export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '') export const $currentServiceTier = atom('') -export const $currentFastMode = atom(false) +export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false)) // Effective approval-bypass state mirrored from the gateway (session.info). // Persistence lives in the backend config (approvals.mode), so this is a plain // reflection of the truth the gateway reports rather than its own store. @@ -254,11 +264,29 @@ export const setMessages = (next: Updater) => updateAtom($message export const setFreshDraftReady = (next: Updater) => updateAtom($freshDraftReady, next) export const setBusy = (next: Updater) => updateAtom($busy, next) export const setAwaitingResponse = (next: Updater) => updateAtom($awaitingResponse, next) -export const setCurrentModel = (next: Updater) => updateAtom($currentModel, next) -export const setCurrentProvider = (next: Updater) => updateAtom($currentProvider, next) -export const setCurrentReasoningEffort = (next: Updater) => updateAtom($currentReasoningEffort, next) + +export const setCurrentModel = (next: Updater) => { + updateAtom($currentModel, next) + persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null) +} + +export const setCurrentProvider = (next: Updater) => { + updateAtom($currentProvider, next) + persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null) +} + +export const setCurrentReasoningEffort = (next: Updater) => { + updateAtom($currentReasoningEffort, next) + persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null) +} + export const setCurrentServiceTier = (next: Updater) => updateAtom($currentServiceTier, next) -export const setCurrentFastMode = (next: Updater) => updateAtom($currentFastMode, next) + +export const setCurrentFastMode = (next: Updater) => { + updateAtom($currentFastMode, next) + persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get()) +} + export const setYoloActive = (next: Updater) => updateAtom($yoloActive, next) export const setCurrentCwd = (next: Updater) => { diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 01f78bc08dcc6..913e4fb11eebf 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -5,6 +5,9 @@ import type { DesktopUpdateStatus } from '@/global' const storage = new Map() vi.mock('@/lib/storage', () => ({ + persistBoolean: (key: string, value: boolean) => { + storage.set(key, String(value)) + }, persistString: (key: string, value: null | string) => { if (value === null) { storage.delete(key) @@ -12,6 +15,11 @@ vi.mock('@/lib/storage', () => ({ storage.set(key, value) } }, + storedBoolean: (key: string, fallback: boolean) => { + const value = storage.get(key) + + return value === undefined ? fallback : value === 'true' + }, storedString: (key: string) => storage.get(key) ?? null })) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2b37b5788bb83..77884c5920e6a 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1851,8 +1851,10 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, cwd=None): - created.append({"key": key, "source": source, "model": model, "cwd": cwd}) + def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + created.append( + {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} + ) monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") @@ -1860,7 +1862,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True}) assert created == [ - {"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)} + {"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": str(tmp_path)} ] @@ -1870,15 +1872,74 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, cwd=None): - created.append({"key": key, "source": source, "model": model, "cwd": cwd}) + def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + created.append( + {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} + ) monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)}) - assert created == [{"key": "k1", "source": "tui", "model": "test-model", "cwd": None}] + assert created == [ + {"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": None} + ] + + +def test_ensure_session_db_row_persists_session_model_override(monkeypatch): + """The session's composer pick (model + effort + fast) must own the DB row. + + Regression for the "switched to gpt-5.5, reconnect snapped back to opus" + bug: the row was created with the global default and won the INSERT-OR-IGNORE + race, so resume rebuilt from the global model and silently reverted the + chat. The override model + a model_config carrying provider/reasoning/ + service_tier must be persisted so session.resume restores all three. + """ + created = [] + + class _FakeDB: + def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + created.append( + {"key": key, "model": model, "model_config": model_config, "cwd": cwd} + ) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "global/default") + + server._ensure_session_db_row( + { + "session_key": "k1", + "model_override": {"model": "openai/gpt-5.5", "provider": "openrouter"}, + "create_reasoning_override": {"effort": "high"}, + "create_service_tier_override": "priority", + } + ) + + assert len(created) == 1 + row = created[0] + assert row["model"] == "openai/gpt-5.5" + assert row["model_config"]["model"] == "openai/gpt-5.5" + assert row["model_config"]["provider"] == "openrouter" + assert row["model_config"]["reasoning_config"] == {"effort": "high"} + assert row["model_config"]["service_tier"] == "priority" + + +def test_ensure_session_db_row_no_override_uses_global(monkeypatch): + """A chat that made no explicit pick falls back to the global model and + writes no model_config (so it tracks the profile default).""" + created = [] + + class _FakeDB: + def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + created.append({"model": model, "model_config": model_config}) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "global/default") + + server._ensure_session_db_row({"session_key": "k1", "model_override": None}) + + assert created == [{"model": "global/default", "model_config": None}] def test_session_title_clears_pending_after_persist(monkeypatch): @@ -7485,3 +7546,97 @@ def test_reap_idle_sessions_closes_only_evictable(monkeypatch): assert closed == [("stale", "idle_timeout")] finally: server._sessions.clear() + + +def test_session_create_records_ui_model_as_session_override(monkeypatch): + """The desktop composer owns its model as plain UI state and ships it on + session.create. The gateway must record it as a PER-SESSION override (built + into the agent), never a global config write — picking a model for a new chat + must not mutate the profile default. + """ + monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) + # Don't run the real deferred build in this storage-focused test. + monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None) + try: + resp = server._methods["session.create"]( + "r1", + { + "cols": 80, + "model": "claude-sonnet-4.6", + "provider": "anthropic", + "reasoning_effort": "high", + "fast": True, + }, + ) + sid = resp["result"]["session_id"] + sess = server._sessions[sid] + assert sess["model_override"] == {"model": "claude-sonnet-4.6", "provider": "anthropic"} + assert sess["create_reasoning_override"] is not None + assert sess["create_service_tier_override"] == "priority" + # The immediate response reflects the override (not the global default) so + # the client never clobbers its sticky pick before the build lands. + assert resp["result"]["info"]["model"] == "claude-sonnet-4.6" + assert resp["result"]["info"]["provider"] == "anthropic" + + # No knobs → no overrides; the session builds from the profile default. + plain = server._methods["session.create"]("r2", {"cols": 80}) + plain_sess = server._sessions[plain["result"]["session_id"]] + assert plain_sess["model_override"] is None + assert plain_sess["create_reasoning_override"] is None + assert plain_sess["create_service_tier_override"] is None + finally: + server._sessions.clear() + + +def test_start_agent_build_passes_session_model_override(monkeypatch): + """A model staged on the session (e.g. by session.create from the desktop + composer) must reach _make_agent so the first build runs on it directly — + no global config, no build-then-switch. + """ + captured = {} + + class FakeWorker: + def __init__(self, *_a, **_k): + pass + + def close(self): + pass + + def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs): + captured.update(kwargs) + return types.SimpleNamespace(model="claude-sonnet-4.6") + + monkeypatch.setattr(server, "_set_session_context", lambda target: []) + monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None) + monkeypatch.setattr(server, "_make_agent", fake_make_agent) + monkeypatch.setattr(server, "_SlashWorker", FakeWorker) + monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_emit", lambda *a, **k: None) + monkeypatch.setattr(server, "_session_info", lambda *a, **k: {}) + monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None) + monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None) + monkeypatch.setattr(server, "_probe_config_health", lambda *_a: None) + + sid = "build-sid" + override = {"model": "claude-sonnet-4.6", "provider": "anthropic"} + reasoning = {"enabled": True, "effort": "high"} + session = { + "agent": None, + "agent_ready": threading.Event(), + "session_key": "k1", + "profile_home": None, + "model_override": override, + "create_reasoning_override": reasoning, + "create_service_tier_override": "priority", + } + server._sessions[sid] = session + try: + server._start_agent_build(sid, session) + assert session["agent_ready"].wait(timeout=3), "agent build did not finish" + assert captured.get("model_override") == override + assert captured.get("reasoning_config_override") == reasoning + assert captured.get("service_tier_override") == "priority" + assert session["agent"].model == "claude-sonnet-4.6" + finally: + server._sessions.clear() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4d12a1a417bb7..d0e52635e7c44 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -946,6 +946,15 @@ def _start_agent_build(sid: str, session: dict) -> None: kw = {"session_db": session_db} if resume_sid := current.get("resume_session_id"): kw["session_id"] = resume_sid + # Model/effort/fast the desktop picked for a brand-new chat ride + # in as per-session overrides so the first build uses them + # directly (no global config, no build-then-switch). + if override := current.get("model_override"): + kw["model_override"] = override + if (reasoning := current.get("create_reasoning_override")) is not None: + kw["reasoning_config_override"] = reasoning + if (tier := current.get("create_service_tier_override")) is not None: + kw["service_tier_override"] = tier agent = _make_agent(sid, key, **kw) finally: _clear_session_context(tokens) @@ -1174,11 +1183,38 @@ def _ensure_session_db_row(session: dict) -> None: close_db = False if db is None: return + # The session's own model/effort/fast pick — the composer override shipped on + # session.create, or a restored /model switch — must own the row's model + + # model_config. The agent isn't built yet at first prompt.submit, so derive + # the row from the live override dict; fall back to the global resolved model + # only when this chat made no explicit pick. Writing the global default here + # used to win the INSERT-OR-IGNORE race against the agent's own correct + # lazy-create, so a reconnect/resume rebuilt from the global model and + # silently reverted the chat (e.g. picked gpt-5.5, reconnect snapped back to + # the profile default). model_config carries provider/reasoning/service_tier + # so resume restores effort + fast too, not just the model name. + override = session.get("model_override") + override = override if isinstance(override, dict) else {} + row_model = str(override.get("model") or "").strip() or _resolve_model() + model_config: dict = {} + for src_key, cfg_key in ( + ("model", "model"), + ("provider", "provider"), + ("base_url", "base_url"), + ("api_mode", "api_mode"), + ): + if val := override.get(src_key): + model_config[cfg_key] = str(val) + if (reasoning := session.get("create_reasoning_override")) is not None: + model_config["reasoning_config"] = reasoning + if tier := session.get("create_service_tier_override"): + model_config["service_tier"] = tier try: db.create_session( key, source="tui", - model=_resolve_model(), + model=row_model, + model_config=model_config or None, cwd=_session_cwd(session) if session.get("explicit_cwd") else None, ) except Exception: @@ -3887,6 +3923,29 @@ def _(rid, params: dict) -> dict: profile = (params.get("profile") or "").strip() or None profile_home = _profile_home(profile) + # The desktop composer owns its model/effort/fast as plain UI state and ships + # it on every session.create. Honor each as a PER-SESSION override (built into + # the agent below) — never a global config write, so picking a model/effort + # for a new chat can't mutate the profile default. provider is optional + # (resolved at build). + create_model = str(params.get("model") or "").strip() + session_model_override = ( + {"model": create_model, "provider": str(params.get("provider") or "").strip() or None} + if create_model + else None + ) + create_reasoning_override = None + if effort := str(params.get("reasoning_effort") or "").strip(): + try: + from hermes_constants import parse_reasoning_effort + + create_reasoning_override = parse_reasoning_effort(effort) + except Exception: + create_reasoning_override = None + # Only pin "fast" when explicitly requested; leaving it None lets the build + # fall back to the profile default service tier rather than forcing normal. + create_service_tier_override = "priority" if params.get("fast") else None + ready = threading.Event() now = time.time() lease, limit_message = _claim_active_session_slot(key, live_session_id=sid) @@ -3912,6 +3971,9 @@ def _(rid, params: dict) -> dict: "cwd": resolved_cwd, "inflight_turn": None, "last_active": now, + "model_override": session_model_override, + "create_reasoning_override": create_reasoning_override, + "create_service_tier_override": create_service_tier_override, "pending_title": title or None, "profile_home": str(profile_home) if profile_home is not None else None, "running": False, @@ -3951,7 +4013,20 @@ def _(rid, params: dict) -> dict: "message_count": len(history), "messages": _history_to_messages(history), "info": { - "model": _resolve_model(), + # Reflect the per-session model override (desktop composer pick) + # in the immediate response so the client doesn't briefly clobber + # its sticky pick with the global default before the deferred + # build's session.info lands. + "model": ( + session_model_override.get("model") + if session_model_override + else _resolve_model() + ), + **( + {"provider": session_model_override["provider"]} + if session_model_override and session_model_override.get("provider") + else {} + ), "tools": {}, "skills": {}, "cwd": _sessions[sid]["cwd"], diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 5f132793f2112..87639ce38184e 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -50,11 +50,18 @@ The center of the app. You get: The bar along the bottom of the chat shows live session state and exposes quick controls without opening Settings: -- **Inline model picker** — switch the model for the active session straight from the status bar. - **Per-session YOLO toggle** — flip YOLO on or off for just this session (matching the TUI). YOLO bypasses the dangerous-command approval prompts, so know what you're turning off — see [Security → YOLO Mode](./security.md#yolo-mode). Chatting against a Hermes instance on another machine instead of the bundled local backend? See [Connecting to a remote backend](#connecting-to-a-remote-backend) below — and for the full picture of how the remote-hosted dashboard connection works (the auth gate, the `/api/ws` chat socket, and WebSocket close-code triage), see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend). +#### 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 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. (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. +- **Per-model effort/fast presets.** Each model remembers its own reasoning effort and fast-mode choice in the desktop app, re-applied to the session whenever you pick that model. These presets are a desktop convenience and don't change crons or subagents. + ### File browser Explore and preview the working directory without leaving the app — useful for following along as the agent reads, writes, and edits files. Set the initial project directory with `hermes desktop --cwd ` (or the `HERMES_DESKTOP_CWD` environment variable). From 7d938cc5c9c7beff22e7cb48886cba33753a5376 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 16 Jun 2026 09:50:17 -0500 Subject: [PATCH 7/8] fix(desktop): keep live model switch metadata truthful A live config.set model switch already moved the next API call to the new model, but the conversation could still restore an old sessions.system_prompt snapshot whose Model/Provider lines named the previous runtime. That made "what model are you?" answer from stale metadata even while inference ran on the new model. After a live switch we now refresh the stored system prompt and append a real system-history pivot (not a fake user turn) so the transcript itself records the new model/provider. Restore also rejects already-stale prompt snapshots when their Model/Provider lines disagree with the runtime, so existing bad sessions self-heal. --- agent/conversation_loop.py | 35 +++++++++++- tests/agent/test_system_prompt_restore.py | 42 ++++++++++++++ tests/test_tui_gateway_server.py | 42 ++++++++++++++ tui_gateway/server.py | 67 +++++++++++++++++++++++ 4 files changed, 185 insertions(+), 1 deletion(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 379a038a9e09b..45722d2657fad 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) agent.session_id, exc, ) - if stored_prompt: + if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt): # Continuing session — reuse the exact system prompt from the # previous turn so the Anthropic cache prefix matches. agent._cached_system_prompt = stored_prompt return + if stored_prompt: + stored_state = "stale_runtime" + logger.info( + "Stored system prompt for session %s has stale runtime identity; " + "rebuilding for model=%s provider=%s.", + agent.session_id, + getattr(agent, "model", "") or "", + getattr(agent, "provider", "") or "", + ) if conversation_history and stored_state in ("null", "empty"): # Continuing session whose stored prompt is unusable. The @@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) ) +def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: + """Return False when the persisted Model/Provider lines are stale.""" + + def line_value(label: str) -> str: + prefix = f"{label}:" + value = "" + for line in prompt.splitlines(): + if line.startswith(prefix): + value = line[len(prefix):].strip() + return value + + stored_model = line_value("Model") + current_model = str(getattr(agent, "model", "") or "").strip() + if stored_model and current_model and stored_model != current_model: + return False + + stored_provider = line_value("Provider") + current_provider = str(getattr(agent, "provider", "") or "").strip() + if stored_provider and current_provider and stored_provider != current_provider: + return False + + return True + + def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str: if is_partial_stub and dropped_tools: tool_list = ", ".join(dropped_tools[:3]) diff --git a/tests/agent/test_system_prompt_restore.py b/tests/agent/test_system_prompt_restore.py index ecfd57b1dfefd..956c1152a42b1 100644 --- a/tests/agent/test_system_prompt_restore.py +++ b/tests/agent/test_system_prompt_restore.py @@ -29,6 +29,7 @@ def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"): agent._cached_system_prompt = None agent.session_id = "test-session-id" agent.model = "test-model" + agent.provider = "openrouter" agent.platform = "cli" agent._session_db = session_db agent._build_system_prompt = MagicMock(return_value=prebuilt_prompt) @@ -67,6 +68,47 @@ class TestStoredPromptReuse: _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) assert agent._cached_system_prompt == stored + def test_present_row_with_stale_runtime_identity_rebuilds(self, caplog): + """Stored prompts are cache gold unless their runtime identity is stale. + + A live /model switch updates the agent and DB model_config immediately. + If the old system_prompt snapshot still says the previous model, + blindly restoring it makes the next turn call the new model while the + model reads old `Model:` metadata ("what model are you?" lies). + """ + stored = ( + "You are Hermes Agent.\n\n" + "Conversation started: Tuesday, June 16, 2026\n" + "Session ID: test-session-id\n" + "Model: anthropic/claude-opus-4.8-fast\n" + "Provider: openrouter" + ) + db = MagicMock() + db.get_session.return_value = {"system_prompt": stored} + agent = _make_agent( + session_db=db, + prebuilt_prompt=( + "You are Hermes Agent.\n\n" + "Conversation started: Tuesday, June 16, 2026\n" + "Session ID: test-session-id\n" + "Model: openai/gpt-5.5\n" + "Provider: openrouter" + ), + ) + agent.model = "openai/gpt-5.5" + + with caplog.at_level(logging.INFO, logger="agent.conversation_loop"): + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + assert agent._cached_system_prompt.endswith( + "Model: openai/gpt-5.5\nProvider: openrouter" + ) + agent._build_system_prompt.assert_called_once_with(None) + db.update_system_prompt.assert_called_once_with( + agent.session_id, agent._cached_system_prompt + ) + assert any("stale runtime identity" in r.getMessage() for r in caplog.records) + # --------------------------------------------------------------------------- # Legitimate fresh-build paths (no history, no DB) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 77884c5920e6a..2ab4128bb200d 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3326,12 +3326,39 @@ def test_config_set_model_switches_agent_without_touching_env(monkeypatch): provider = "openai-codex" base_url = "" api_key = "" + session_id = "sid" + _cached_system_prompt = "Model: gpt-5.3-codex\nProvider: openai-codex" def switch_model(self, **kwargs): self.model = kwargs["new_model"] self.provider = kwargs["new_provider"] + def _build_system_prompt(self, _system_message=None): + return f"Model: {self.model}\nProvider: {self.provider}" + + class SessionDB: + def __init__(self): + self.model_config = None + self.system_prompt = None + self.messages = [] + + def get_session(self, _session_id): + return {"model_config": self.model_config} + + def update_session_meta(self, _session_id, model_config_json, _model=None): + self.model_config = model_config_json + + def update_system_prompt(self, _session_id, system_prompt): + self.system_prompt = system_prompt + + def append_message(self, session_id, role, content=None, **_kwargs): + self.messages.append( + {"session_id": session_id, "role": role, "content": content} + ) + agent = Agent() + db = SessionDB() + agent._session_db = db session = _session(agent=agent) server._sessions["sid"] = session monkeypatch.setenv("HERMES_TUI_PROVIDER", "openai-codex") @@ -3373,6 +3400,21 @@ def test_config_set_model_switches_agent_without_touching_env(monkeypatch): # ...override recorded on the session... assert session["model_override"]["model"] == "anthropic/claude-sonnet-4.6" assert session["model_override"]["provider"] == "anthropic" + # ...the persisted prompt snapshot tracks the new runtime identity too. + # Without this, the next turn restored the old system prompt from the DB: + # API calls went to the new model, but "what model are you?" still read + # "Model: old/model" from the stored prompt. + assert db.system_prompt == ( + "Model: anthropic/claude-sonnet-4.6\nProvider: anthropic" + ) + assert agent._cached_system_prompt == db.system_prompt + assert session["history"][-1]["role"] == "system" + assert "changed to anthropic/claude-sonnet-4.6" in session["history"][-1]["content"] + assert db.messages[-1] == { + "session_id": "session-key", + "role": "system", + "content": session["history"][-1]["content"], + } # ...and the shared process env was NOT touched. assert os.environ["HERMES_TUI_PROVIDER"] == "openai-codex" assert "HERMES_MODEL" not in os.environ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d0e52635e7c44..072a0c959b624 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1673,6 +1673,69 @@ def _persist_live_session_runtime(session: dict | None) -> None: logger.debug("failed to persist live session runtime", exc_info=True) +def _persist_live_session_system_prompt(session: dict | None) -> None: + """Refresh the stored system prompt after a live runtime identity change.""" + if not session: + return + agent = session.get("agent") + session_key = str(session.get("session_key") or "").strip() + if agent is None or not session_key or not hasattr(agent, "_build_system_prompt"): + return + + db = getattr(agent, "_session_db", None) or _get_db() + if db is None or not hasattr(db, "update_system_prompt"): + return + + try: + prompt = agent._build_system_prompt(None) + agent._cached_system_prompt = prompt + db.update_system_prompt(getattr(agent, "session_id", None) or session_key, prompt) + except Exception: + logger.debug("failed to persist live session system prompt", exc_info=True) + + +def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None: + """Record a real system-history pivot after a live model switch.""" + if not session: + return + session_key = str(session.get("session_key") or "").strip() + if not session_key: + return + + provider_part = f" via provider {provider}" if provider else "" + marker = ( + "[System: The active model for this chat has changed to " + f"{model}{provider_part}. From this point forward, use this runtime " + "metadata when answering questions about what model/provider is active.]" + ) + entry = {"role": "system", "content": marker} + + lock = session.get("history_lock") + if lock is not None: + with lock: + session.setdefault("history", []).append(entry) + session["history_version"] = int(session.get("history_version", 0)) + 1 + else: + session.setdefault("history", []).append(entry) + session["history_version"] = int(session.get("history_version", 0)) + 1 + + try: + agent = session.get("agent") + db = getattr(agent, "_session_db", None) if agent is not None else None + if db is not None: + db.append_message(session_id=session_key, role="system", content=marker) + return + + _ensure_session_db_row(session) + with _session_db(session) as scoped_db: + if scoped_db is not None: + scoped_db.append_message( + session_id=session_key, role="system", content=marker + ) + except Exception: + logger.debug("failed to persist model switch marker", exc_info=True) + + def _write_config_key(key_path: str, value): cfg = _load_cfg() current = cfg @@ -2092,6 +2155,10 @@ def _apply_model_switch( ) _restart_slash_worker(sid, session) _persist_live_session_runtime(session) + _persist_live_session_system_prompt(session) + _append_model_switch_marker( + session, model=result.new_model, provider=result.target_provider + ) _emit("session.info", sid, _session_info(agent, session)) # Record the switch as a PER-SESSION override so a later rebuild of THIS From 80e4b8985ea971538462fe129e67ff510b3cec0a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 16 Jun 2026 09:50:27 -0500 Subject: [PATCH 8/8] feat(desktop): tighten composer model picker interactions Clicking a model row in the composer dropdown now commits and closes the menu (via a close context); the hover-revealed reasoning/fast submenu stays open to tweak. The pill shows a quiet braille loader instead of literal "No model" until one resolves, and steer takes over the mic slot while typing into a running agent. --- .../src/app/chat/composer/controls.tsx | 8 ++++++-- .../src/app/chat/composer/model-pill.tsx | 20 ++++++++++++++++--- .../src/app/shell/model-menu-panel.tsx | 13 +++++++++++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index b79753804c1b5..6d748c73b5f6a 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -67,6 +67,7 @@ export function ComposerControls({ const c = t.composer const steerCombo = formatCombo('mod+enter') const steerLabel = `${c.steer} (${steerCombo})` + const steerTip = ( {c.steer} @@ -83,8 +84,9 @@ export function ComposerControls({ return (
- - {canSteer && ( + {/* While the agent runs and the user is typing, steer takes over the mic's + slot rather than crowding the row with an extra button. */} + {canSteer ? ( + ) : ( + )} {showVoicePrimary ? ( diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 0ea963a3628fa..f04b6e2302b10 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -1,7 +1,10 @@ import { useStore } from '@nanostores/react' +import { useState } from 'react' +import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' import { ChevronDown } from '@/lib/icons' import { formatModelStatusLabel } from '@/lib/model-status-label' @@ -32,13 +35,22 @@ export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatB const currentProvider = useStore($currentProvider) const fastMode = useStore($currentFastMode) const reasoningEffort = useStore($currentReasoningEffort) + const [open, setOpen] = useState(false) + // The model resolves a beat after the gateway/session comes up. Rather than + // flash a literal "No model", show a quiet loader (inherits the pill text + // color at half opacity) until a model lands. const label = ( <> - {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + {currentModel.trim() ? ( + {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + ) : ( + + )} ) + const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel if (!model.modelMenuContent) { @@ -58,14 +70,16 @@ export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatB } return ( - + - {model.modelMenuContent} + setOpen(false)}> + {model.modelMenuContent} + ) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index b87b1a030d16e..a9795564aab1d 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' -import { useMemo, useState } from 'react' +import { createContext, useContext, useMemo, useState } from 'react' import { Codicon } from '@/components/ui/codicon' import { @@ -41,6 +41,11 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' +// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so +// clicking a model row commits + closes, while the hover-revealed edit submenu +// (reasoning/fast) stays open to play with (its items preventDefault on select). +export const ModelMenuCloseContext = createContext<() => void>(() => {}) + interface ModelMenuPanelProps { gateway?: HermesGateway onSelectModel: (selection: { model: string; provider: string }) => Promise | void @@ -55,6 +60,7 @@ interface ProviderGroup { export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) { const { t } = useI18n() const copy = t.shell.modelMenu + const closeMenu = useContext(ModelMenuCloseContext) const [search, setSearch] = useState('') // Reactive session state is read from the stores here (not drilled in), so // toggling effort/fast/model re-renders this panel in place without forcing @@ -209,10 +215,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model // restores its preset; the Fast toggle inside swaps to the -fast // sibling (or flips the speed param). The sub-trigger has no // `onSelect`, so wire both click and Enter/Space for keyboard parity. + // Clicking the row commits the model and closes the picker; the + // edit submenu (reasoning/fast) is reached by HOVER, so you can + // still tweak those without the click dismissing everything. const activate = () => { if (!isCurrent) { void selectFamily(family, group.provider) } + + closeMenu() } return (