Merge pull request #46959 from NousResearch/bb/composer-model-selector
feat(desktop): composer model selector, per-model presets & external-provider disconnect
This commit is contained in:
commit
c6e99ab375
@ -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])
|
||||
|
||||
@ -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'
|
||||
@ -66,6 +67,7 @@ export function ComposerControls({
|
||||
const c = t.composer
|
||||
const steerCombo = formatCombo('mod+enter')
|
||||
const steerLabel = `${c.steer} (${steerCombo})`
|
||||
|
||||
const steerTip = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
@ -81,8 +83,10 @@ export function ComposerControls({
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<ModelPill disabled={disabled} model={state.model} />
|
||||
{/* 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 ? (
|
||||
<Tip label={steerTip}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
@ -96,6 +100,8 @@ export function ComposerControls({
|
||||
<SteeringWheel size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
|
||||
86
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
86
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
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'
|
||||
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 [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 = (
|
||||
<>
|
||||
{currentModel.trim() ? (
|
||||
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
|
||||
) : (
|
||||
<GlyphSpinner className="opacity-50" spinner="braille" />
|
||||
)}
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</>
|
||||
)
|
||||
|
||||
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
|
||||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={PILL}
|
||||
disabled={disabled}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title={copy.openModelPicker}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||
{model.modelMenuContent}
|
||||
</ModelMenuCloseContext.Provider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -62,6 +62,7 @@ import { threadLoadingState } from './thread-loading'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
modelMenuContent?: React.ReactNode
|
||||
onToggleSelectedPin: () => void
|
||||
onDeleteSelectedSession: () => void
|
||||
onCancel: () => Promise<void> | 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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -859,7 +861,6 @@ export function DesktopController() {
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
freshDraftReady,
|
||||
openCommandCenterSection,
|
||||
@ -981,6 +982,7 @@ export function DesktopController() {
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
|
||||
@ -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 | string>(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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
onReady={value => (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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<boolean> => {
|
||||
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)
|
||||
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('config.set', {
|
||||
session_id: activeSessionId,
|
||||
key: 'model',
|
||||
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
try {
|
||||
await requestGateway('config.set', {
|
||||
session_id: activeSessionId,
|
||||
key: 'model',
|
||||
value: `${selection.model} --provider ${selection.provider}`
|
||||
})
|
||||
|
||||
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 }
|
||||
|
||||
@ -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<SessionCreateResponse>('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
|
||||
|
||||
@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
/>
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
|
||||
@ -55,7 +55,7 @@ afterEach(() => {
|
||||
async function renderProvidersSettings() {
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
|
||||
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
|
||||
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<string, EnvVarInfo>): 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 && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{p.connected}
|
||||
</p>
|
||||
<GroupLabel>{p.connected}</GroupLabel>
|
||||
{connected.map(p => (
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
onTerminalDisconnect={onTerminalDisconnect}
|
||||
provider={p}
|
||||
/>
|
||||
))}
|
||||
@ -154,6 +171,7 @@ function OAuthPicker({
|
||||
)}
|
||||
{showOthers && (
|
||||
<>
|
||||
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
|
||||
{others.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={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 (
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||
@ -203,13 +226,13 @@ function ConnectedProviderRow({
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.settings.providers.connected}
|
||||
{copy.connected}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
{!canDisconnect && (
|
||||
{showHint && (
|
||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||
{disconnectHint}
|
||||
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
@ -228,6 +251,18 @@ function ConnectedProviderRow({
|
||||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
{terminalDisconnect && (
|
||||
<Button
|
||||
aria-label={`${copy.disconnect} ${title}`}
|
||||
onClick={() => onTerminalDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={copy.disconnectInTerminal}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -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<OAuthProvider[]>([])
|
||||
@ -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
|
||||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => 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
|
||||
}
|
||||
|
||||
@ -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: (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, {
|
||||
fastMode: currentFastMode,
|
||||
reasoningEffort: currentReasoningEffort
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</span>
|
||||
),
|
||||
...(modelMenuContent
|
||||
? {
|
||||
menuAlign: 'end' as const,
|
||||
menuClassName: 'w-64',
|
||||
menuContent: modelMenuContent,
|
||||
title: currentProvider
|
||||
? 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,
|
||||
|
||||
84
apps/desktop/src/app/shell/model-edit-submenu.test.tsx
Normal file
84
apps/desktop/src/app/shell/model-edit-submenu.test.tsx
Normal file
@ -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<unknown> }) {
|
||||
return render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort="medium"
|
||||
fastControl={opts.fastControl}
|
||||
isActive
|
||||
model="m1"
|
||||
onSelectModel={vi.fn()}
|
||||
provider="p1"
|
||||
reasoning={opts.reasoning}
|
||||
requestGateway={opts.requestGateway as never}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// 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' })
|
||||
})
|
||||
})
|
||||
@ -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<boolean> | 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<boolean> | void
|
||||
/** This row's provider slug — edits persist as its global preset. */
|
||||
provider: string
|
||||
/** Whether this model supports reasoning effort. */
|
||||
reasoning: boolean
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
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<boolean> => {
|
||||
if (isActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (await onActivate()) !== false
|
||||
}
|
||||
|
||||
const patchReasoning = async (next: string, rollback: string) => {
|
||||
setCurrentReasoningEffort(next)
|
||||
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
// 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
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'reasoning',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: next
|
||||
})
|
||||
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 {
|
||||
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).
|
||||
// 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') {
|
||||
setCurrentFastMode(enabled)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentFastMode(!enabled)
|
||||
setModelPreset(provider, model, { fast: enabled })
|
||||
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'fast',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: enabled ? 'fast' : 'normal'
|
||||
})
|
||||
setCurrentFastMode(enabled)
|
||||
|
||||
// Preset-only without a session (see patchReasoning).
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
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({
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked =>
|
||||
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
|
||||
}
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
@ -205,10 +207,7 @@ export function ModelEditSubmenu({
|
||||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
||||
value={effort}
|
||||
>
|
||||
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={dropdownMenuRow}
|
||||
|
||||
@ -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 {
|
||||
@ -20,6 +20,7 @@ import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
@ -40,9 +41,14 @@ 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; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
|
||||
onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
@ -54,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
|
||||
@ -63,6 +70,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const modelPresets = useStore($modelPresets)
|
||||
const visibleModels = useStore($visibleModels)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
@ -87,13 +95,41 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
: null
|
||||
|
||||
const providers = modelOptions.data?.providers
|
||||
|
||||
const effectiveVisibleModels = useMemo(
|
||||
() => effectiveVisibleKeys(visibleModels, providers ?? []),
|
||||
[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.
|
||||
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),
|
||||
@ -152,37 +188,42 @@ 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
|
||||
? [
|
||||
const meta = [
|
||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||
reasoningEffortLabel(currentReasoningEffort) || copy.medium
|
||||
(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.
|
||||
// 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 switchTo(family.id, group.provider.slug)
|
||||
void selectFamily(family, group.provider)
|
||||
}
|
||||
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
return (
|
||||
@ -204,10 +245,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort={effEffort}
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
onActivate={() => 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}
|
||||
/>
|
||||
|
||||
@ -140,7 +140,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
? '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(), [])
|
||||
|
||||
@ -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({
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={persistGlobal || !sessionId}
|
||||
disabled={!sessionId}
|
||||
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
||||
/>
|
||||
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
|
||||
<Button onClick={addProvider} variant="ghost">
|
||||
{copy.addProvider}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -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}`,
|
||||
@ -1498,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.',
|
||||
|
||||
@ -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} を削除しました。`,
|
||||
@ -1638,8 +1637,6 @@ export const ja = defineLocale({
|
||||
unknown: '(不明)',
|
||||
search: 'プロバイダーとモデルをフィルター...',
|
||||
noModels: 'モデルが見つかりません。',
|
||||
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
|
||||
persistGlobal: 'グローバルに保持',
|
||||
addProvider: 'プロバイダーを追加',
|
||||
loadFailed: 'モデルを読み込めませんでした',
|
||||
noAuthenticatedProviders: '認証済みプロバイダーがありません。',
|
||||
|
||||
@ -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
|
||||
@ -1141,8 +1145,6 @@ export interface Translations {
|
||||
unknown: string
|
||||
search: string
|
||||
noModels: string
|
||||
persistGlobalSession: string
|
||||
persistGlobal: string
|
||||
addProvider: string
|
||||
loadFailed: string
|
||||
noAuthenticatedProviders: string
|
||||
|
||||
@ -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} 已移除。`,
|
||||
@ -1582,8 +1581,6 @@ export const zhHant = defineLocale({
|
||||
unknown: '(未知)',
|
||||
search: '篩選提供方和模型...',
|
||||
noModels: '找不到模型。',
|
||||
persistGlobalSession: '全域儲存(否則僅限此工作階段)',
|
||||
persistGlobal: '全域儲存',
|
||||
addProvider: '新增提供方',
|
||||
loadFailed: '無法載入模型',
|
||||
noAuthenticatedProviders: '沒有已驗證的提供方。',
|
||||
|
||||
@ -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}`,
|
||||
@ -1679,8 +1683,6 @@ export const zh: Translations = {
|
||||
unknown: '(未知)',
|
||||
search: '筛选提供方和模型...',
|
||||
noModels: '未找到模型。',
|
||||
persistGlobalSession: '全局保存 (否则仅当前会话)',
|
||||
persistGlobal: '全局保存',
|
||||
addProvider: '添加提供方',
|
||||
loadFailed: '无法加载模型',
|
||||
noAuthenticatedProviders: '没有已认证的提供方。',
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
51
apps/desktop/src/store/model-presets.test.ts
Normal file
51
apps/desktop/src/store/model-presets.test.ts
Normal file
@ -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<string, unknown> }[] = []
|
||||
|
||||
const request = async <T>(method: string, params?: Record<string, unknown>) => {
|
||||
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<string, unknown> }[] = []
|
||||
|
||||
const request = async <T>(method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
return {} as T
|
||||
}
|
||||
|
||||
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
86
apps/desktop/src/store/model-presets.ts
Normal file
86
apps/desktop/src/store/model-presets.ts
Normal file
@ -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 = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
/** Stable `provider::model` key (matches the visibility-store format). */
|
||||
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
|
||||
|
||||
function load(): Record<string, ModelPreset> {
|
||||
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<string, ModelPreset>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const $modelPresets = atom<Record<string, ModelPreset>>(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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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> = 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<ChatMessage[]>) => updateAtom($message
|
||||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||||
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
||||
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||||
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
|
||||
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
|
||||
|
||||
export const setCurrentModel = (next: Updater<string>) => {
|
||||
updateAtom($currentModel, next)
|
||||
persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null)
|
||||
}
|
||||
|
||||
export const setCurrentProvider = (next: Updater<string>) => {
|
||||
updateAtom($currentProvider, next)
|
||||
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
|
||||
}
|
||||
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => {
|
||||
updateAtom($currentReasoningEffort, next)
|
||||
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
|
||||
}
|
||||
|
||||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
|
||||
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => {
|
||||
updateAtom($currentFastMode, next)
|
||||
persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get())
|
||||
}
|
||||
|
||||
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
|
||||
@ -5,6 +5,9 @@ import type { DesktopUpdateStatus } from '@/global'
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
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
|
||||
}))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
@ -3265,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")
|
||||
@ -3312,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
|
||||
@ -7497,3 +7600,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()
|
||||
|
||||
@ -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:
|
||||
@ -1637,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
|
||||
@ -2056,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
|
||||
@ -3887,6 +3990,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 +4038,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 +4080,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"],
|
||||
|
||||
@ -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 <path>` (or the `HERMES_DESKTOP_CWD` environment variable).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user