diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 938e3de4..426ce029 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -12,11 +12,13 @@ * - WebSocket message streaming */ -import { query } from '@anthropic-ai/claude-agent-sdk'; import crypto from 'crypto'; import { promises as fs } from 'fs'; -import path from 'path'; import os from 'os'; +import path from 'path'; + +import { query } from '@anthropic-ai/claude-agent-sdk'; + import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; @@ -42,8 +44,7 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']); function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) { - const selectedModel = modelsDefinition.OPTIONS - .find((option) => option.value === model) || null; + const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null; const allowedEfforts = selectedModel?.effort?.values ?.map((value) => value.value) || []; return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort) diff --git a/server/modules/providers/list/codex/codex-models.provider.ts b/server/modules/providers/list/codex/codex-models.provider.ts index 95ea85ce..4266c88b 100644 --- a/server/modules/providers/list/codex/codex-models.provider.ts +++ b/server/modules/providers/list/codex/codex-models.provider.ts @@ -91,29 +91,35 @@ const readCodexPriority = (value: unknown): number => ( typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER ); -const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({ - value: model.slug as string, - label: readOptionalString(model.display_name) ?? (model.slug as string), - description: readOptionalString(model.description), - effort: Array.isArray(model.supported_reasoning_levels) && model.supported_reasoning_levels.length > 0 - ? { - default: readOptionalString(model.default_reasoning_level) ?? undefined, - values: model.supported_reasoning_levels - .map((level) => { - const value = readOptionalString(level?.effort); - if (!value) { - return null; - } +const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => { + const effortValues = Array.isArray(model.supported_reasoning_levels) + ? model.supported_reasoning_levels + .map((level) => { + const value = readOptionalString(level?.effort); + if (!value) { + return null; + } - return { - value, - description: readOptionalString(level?.description), - }; - }) - .filter((level): level is NonNullable => Boolean(level)), - } - : undefined, -}); + return { + value, + description: readOptionalString(level?.description), + }; + }) + .filter((level): level is NonNullable => Boolean(level)) + : []; + + return { + value: model.slug as string, + label: readOptionalString(model.display_name) ?? (model.slug as string), + description: readOptionalString(model.description), + effort: effortValues.length > 0 + ? { + default: readOptionalString(model.default_reasoning_level) ?? undefined, + values: effortValues, + } + : undefined, + }; +}; const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => { const sortedModels = [...models] diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 1b7cbbb3..ea49e3a1 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -21,6 +21,8 @@ type ProviderCapabilities = { supportsPermissionRequests: boolean; /** Whether the token-usage endpoint has data for this provider. */ supportsTokenUsage: boolean; + /** Whether the provider runtime can accept model-level reasoning effort. */ + supportsEffort: boolean; }; /** @@ -38,6 +40,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: true, supportsTokenUsage: true, + supportsEffort: true, }, cursor: { provider: 'cursor', @@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: false, + supportsEffort: false, }, codex: { provider: 'codex', @@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: true, }, gemini: { provider: 'gemini', @@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: false, }, opencode: { provider: 'opencode', @@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: false, }, }; diff --git a/src/components/chat/constants/providerEffort.ts b/src/components/chat/constants/providerEffort.ts new file mode 100644 index 00000000..5a930733 --- /dev/null +++ b/src/components/chat/constants/providerEffort.ts @@ -0,0 +1,12 @@ +import type { LLMProvider, ProviderModelOption } from '../../../types/app'; + +export const DEFAULT_EFFORT_VALUE = 'default'; + +export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial> = { + claude: ['low', 'medium', 'high', 'xhigh', 'max'], + codex: ['low', 'medium', 'high', 'xhigh'], +}; + +export const toProviderEffortOptions = ( + values: readonly string[], +): NonNullable['values'] => values.map((value) => ({ value })); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index f9365dfd..9dd1bbc0 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -34,11 +34,11 @@ interface UseChatComposerStateArgs { provider: LLMProvider; permissionMode: PermissionMode | string; cyclePermissionMode: () => void; + resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode; cursorModel: string; claudeModel: string; codexModel: string; - claudeEffort: string; - codexEffort: string; + currentProviderEffort: string; geminiModel: string; opencodeModel: string; isLoading: boolean; @@ -163,17 +163,6 @@ const getNotificationSessionSummary = ( return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback; }; -const getLatestProviderEffort = ( - provider: LLMProvider, - fallbackEffort: string, -): string => { - if (provider !== 'claude' && provider !== 'codex') { - return 'default'; - } - - return safeLocalStorage.getItem(`${provider}-effort`) || fallbackEffort; -}; - export function useChatComposerState({ selectedProject, selectedSession, @@ -181,11 +170,11 @@ export function useChatComposerState({ provider, permissionMode, cyclePermissionMode, + resolvePermissionModeForProvider, cursorModel, claudeModel, codexModel, - claudeEffort, - codexEffort, + currentProviderEffort, geminiModel, opencodeModel, isLoading, @@ -743,14 +732,9 @@ export function useChatComposerState({ : provider === 'gemini' ? geminiModel : provider === 'opencode' - ? opencodeModel - : claudeModel; - const effort = - provider === 'claude' - ? getLatestProviderEffort(provider, claudeEffort) - : provider === 'codex' - ? getLatestProviderEffort(provider, codexEffort) - : 'default'; + ? opencodeModel + : claudeModel; + const effort = currentProviderEffort; // One message shape for every provider. The backend resolves the // provider, project path, and provider-native resume id from the @@ -762,9 +746,7 @@ export function useChatComposerState({ options: { model, effort, - // Codex has no plan mode; downgrade rather than sending an - // unsupported value to its runtime. - permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode, + permissionMode: resolvePermissionModeForProvider(provider, permissionMode), toolsSettings, skipPermissions: toolsSettings?.skipPermissions || false, sessionSummary, @@ -790,9 +772,8 @@ export function useChatComposerState({ selectedSession, attachedImages, claudeModel, - claudeEffort, codexModel, - codexEffort, + currentProviderEffort, currentSessionId, cursorModel, executeCommand, @@ -803,6 +784,7 @@ export function useChatComposerState({ onSessionEstablished, permissionMode, provider, + resolvePermissionModeForProvider, resetCommandMenuState, scrollToBottom, selectedProject, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 3353c429..35fdedd4 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + import { authenticatedFetch } from '../../../utils/api'; import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { @@ -9,6 +10,11 @@ import type { ProviderModelsCacheInfo, ProviderModelsDefinition, } from '../../../types/app'; +import { + DEFAULT_EFFORT_VALUE, + FALLBACK_PROVIDER_EFFORT_VALUES, + toProviderEffortOptions, +} from '../constants/providerEffort'; const FALLBACK_DEFAULT_MODEL: Record = { claude: 'default', @@ -18,12 +24,7 @@ const FALLBACK_DEFAULT_MODEL: Record = { opencode: 'anthropic/claude-sonnet-4-5', }; -const DEFAULT_EFFORT_VALUE = 'default'; - -const FALLBACK_EFFORT_VALUES: Partial> = { - claude: ['low', 'medium', 'high', 'xhigh', 'max'], - codex: ['low', 'medium', 'high', 'xhigh'], -}; +const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; /** * Fallback permission-mode matrix used only until the backend capability @@ -47,6 +48,7 @@ type ProviderCapabilities = { supportsAbort: boolean; supportsPermissionRequests: boolean; supportsTokenUsage: boolean; + supportsEffort?: boolean; }; type ProviderCapabilitiesApiResponse = { @@ -80,7 +82,7 @@ type ChangeActiveModelApiResponse = { }; }; -export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) { +export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) { const [permissionMode, setPermissionMode] = useState('default'); const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); const [provider, setProvider] = useState(() => { @@ -95,11 +97,11 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const [codexModel, setCodexModel] = useState(() => { return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; }); - const [claudeEffort, setClaudeEffort] = useState(() => { - return localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE; - }); - const [codexEffort, setCodexEffort] = useState(() => { - return localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE; + const [providerEfforts, setProviderEfforts] = useState>>(() => { + return PROVIDERS.reduce>>((acc, targetProvider) => { + acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE; + return acc; + }, {}); }); const [geminiModel, setGeminiModel] = useState(() => { return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini; @@ -160,20 +162,15 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh }, []); const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => { - if (targetProvider === 'claude') { - setClaudeEffort(effort); - localStorage.setItem('claude-effort', effort); - return; - } - - if (targetProvider === 'codex') { - setCodexEffort(effort); - localStorage.setItem('codex-effort', effort); - } + setProviderEfforts((previous) => ( + previous[targetProvider] === effort + ? previous + : { ...previous, [targetProvider]: effort } + )); + localStorage.setItem(`${targetProvider}-effort`, effort); }, []); const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => { - const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; const requestId = providerModelsRequestIdRef.current + 1; providerModelsRequestIdRef.current = requestId; const isHardRefresh = options.bypassCache === true; @@ -186,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh try { const results = await Promise.all( - providers.map(async (p) => { + PROVIDERS.map(async (p) => { const params = new URLSearchParams(); if (options.bypassCache) { params.set('bypassCache', 'true'); @@ -210,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const nextCatalog: Partial> = {}; const nextCacheCatalog: Partial> = {}; - providers.forEach((p, i) => { + PROVIDERS.forEach((p, i) => { const entry = results[i]; if (!entry) { return; @@ -271,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default']; }, [providerCapabilities]); + const getDefaultPermissionModeForProvider = useCallback((targetProvider: LLMProvider): PermissionMode => { + const modes = getPermissionModesForProvider(targetProvider); + const capabilityDefault = providerCapabilities?.[targetProvider]?.defaultPermissionMode as PermissionMode | undefined; + if (capabilityDefault && modes.includes(capabilityDefault)) { + return capabilityDefault; + } + return modes[0] ?? 'default'; + }, [getPermissionModesForProvider, providerCapabilities]); + + const getSupportsEffortForProvider = useCallback((targetProvider: LLMProvider): boolean => { + const capabilitySupport = providerCapabilities?.[targetProvider]?.supportsEffort; + if (typeof capabilitySupport === 'boolean') { + return capabilitySupport; + } + return Boolean(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider]?.length); + }, [providerCapabilities]); + const pickStoredOrCurrent = ( storageKey: string, current: string, @@ -302,9 +316,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh targetProvider: LLMProvider, model: string, ): string[] => { + if (!getSupportsEffortForProvider(targetProvider)) { + return []; + } + const option = getModelOption(targetProvider, model); - return option?.effort?.values.map((value) => value.value) ?? FALLBACK_EFFORT_VALUES[targetProvider] ?? []; - }, [getModelOption]); + if (option) { + return option.effort?.values.map((value) => value.value) ?? []; + } + + return [...(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? [])]; + }, [getModelOption, getSupportsEffortForProvider]); const reconcileStoredEffort = useCallback(( targetProvider: LLMProvider, @@ -316,16 +338,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return DEFAULT_EFFORT_VALUE; } - const storageKey = `${targetProvider}-effort`; - const storedEffort = localStorage.getItem(storageKey); - if (storedEffort === DEFAULT_EFFORT_VALUE || storedEffort === null) { + if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) { return DEFAULT_EFFORT_VALUE; } - if (allowedValues.includes(storedEffort)) { - return storedEffort; - } - if (allowedValues.includes(currentEffort)) { return currentEffort; } @@ -333,6 +349,14 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return DEFAULT_EFFORT_VALUE; }, [getAllowedEffortValues]); + const providerModels = useMemo>(() => ({ + claude: claudeModel, + cursor: cursorModel, + codex: codexModel, + gemini: geminiModel, + opencode: opencodeModel, + }), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]); + useEffect(() => { const claude = providerModelCatalog.claude; if (claude) { @@ -346,16 +370,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [providerModelCatalog.claude, claudeModel]); - useEffect(() => { - const next = reconcileStoredEffort('claude', claudeModel, claudeEffort); - if (next !== claudeEffort) { - setClaudeEffort(next); - } - if ((localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE) !== next) { - localStorage.setItem('claude-effort', next); - } - }, [claudeEffort, claudeModel, reconcileStoredEffort]); - useEffect(() => { const cursor = providerModelCatalog.cursor; if (cursor) { @@ -382,16 +396,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [providerModelCatalog.codex, codexModel]); - useEffect(() => { - const next = reconcileStoredEffort('codex', codexModel, codexEffort); - if (next !== codexEffort) { - setCodexEffort(next); - } - if ((localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE) !== next) { - localStorage.setItem('codex-effort', next); - } - }, [codexEffort, codexModel, reconcileStoredEffort]); - useEffect(() => { const gemini = providerModelCatalog.gemini; if (gemini) { @@ -418,6 +422,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [providerModelCatalog.opencode, opencodeModel]); + useEffect(() => { + const nextEfforts: Partial> = {}; + let hasUpdates = false; + + for (const targetProvider of PROVIDERS) { + const currentEffort = providerEfforts[targetProvider] ?? DEFAULT_EFFORT_VALUE; + const nextEffort = reconcileStoredEffort(targetProvider, providerModels[targetProvider], currentEffort); + if (nextEffort === currentEffort) { + continue; + } + + nextEfforts[targetProvider] = nextEffort; + localStorage.setItem(`${targetProvider}-effort`, nextEffort); + hasUpdates = true; + } + + if (hasUpdates) { + setProviderEfforts((previous) => ({ ...previous, ...nextEfforts })); + } + }, [providerEfforts, providerModels, reconcileStoredEffort]); + useEffect(() => { if (!selectedSession?.id) { return; @@ -425,8 +450,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const validModes = getPermissionModesForProvider(provider); - setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default'); - }, [selectedSession?.id, provider, getPermissionModesForProvider]); + setPermissionMode( + savedMode && validModes.includes(savedMode) + ? savedMode + : getDefaultPermissionModeForProvider(provider), + ); + }, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]); useEffect(() => { if (!selectedSession?.__provider || selectedSession.__provider === provider) { @@ -480,6 +509,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]); + const resolvePermissionModeForProvider = useCallback(( + targetProvider: LLMProvider, + requestedMode: PermissionMode | string, + ): PermissionMode => { + const validModes = getPermissionModesForProvider(targetProvider); + return validModes.includes(requestedMode as PermissionMode) + ? requestedMode as PermissionMode + : getDefaultPermissionModeForProvider(targetProvider); + }, [getDefaultPermissionModeForProvider, getPermissionModesForProvider]); + const selectProviderModel = useCallback(async ( targetProvider: LLMProvider, model: string, @@ -515,6 +554,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh }; }, [setStoredProviderModel]); + const currentProviderEffort = providerEfforts[provider] ?? DEFAULT_EFFORT_VALUE; + const currentProviderEffortOptions = useMemo(() => { + if (!getSupportsEffortForProvider(provider)) { + return []; + } + + const option = getModelOption(provider, providerModels[provider]); + if (option) { + return option.effort?.values ?? []; + } + + return toProviderEffortOptions(FALLBACK_PROVIDER_EFFORT_VALUES[provider] ?? []); + }, [getModelOption, getSupportsEffortForProvider, provider, providerModels]); + return { provider, setProvider, @@ -524,10 +577,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh setClaudeModel, codexModel, setCodexModel, - claudeEffort, - setClaudeEffort, - codexEffort, - setCodexEffort, + currentProviderEffort, + currentProviderEffortOptions, geminiModel, setGeminiModel, opencodeModel, @@ -544,5 +595,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }), selectProviderModel, setStoredProviderEffort, + resolvePermissionModeForProvider, }; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 570ea560..9fffb754 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -16,11 +16,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; -const FALLBACK_COMPOSER_EFFORT_OPTIONS = { - claude: ['low', 'medium', 'high', 'xhigh', 'max'].map((value) => ({ value })), - codex: ['low', 'medium', 'high', 'xhigh'].map((value) => ({ value })), -} as const; - function ChatInterface({ selectedProject, selectedSession, @@ -75,10 +70,8 @@ function ChatInterface({ setClaudeModel, codexModel, setCodexModel, - claudeEffort, - setClaudeEffort, - codexEffort, - setCodexEffort, + currentProviderEffort, + currentProviderEffortOptions, geminiModel, setGeminiModel, opencodeModel, @@ -94,6 +87,7 @@ function ChatInterface({ hardRefreshProviderModels, selectProviderModel, setStoredProviderEffort, + resolvePermissionModeForProvider, } = useChatProviderState({ selectedSession, selectedProject, @@ -208,8 +202,7 @@ function ChatInterface({ cursorModel, claudeModel, codexModel, - claudeEffort, - codexEffort, + currentProviderEffort, geminiModel, opencodeModel, isLoading: isProcessing, @@ -226,39 +219,9 @@ function ChatInterface({ addMessage, setIsUserScrolledUp, setPendingPermissionRequests, + resolvePermissionModeForProvider, }); - const currentComposerModel = useMemo(() => { - if (provider === 'claude') return claudeModel; - if (provider === 'codex') return codexModel; - if (provider === 'gemini') return geminiModel; - if (provider === 'opencode') return opencodeModel; - return cursorModel; - }, [provider, claudeModel, codexModel, geminiModel, opencodeModel, cursorModel]); - - const composerEffort = useMemo(() => { - if (provider === 'claude') return claudeEffort; - if (provider === 'codex') return codexEffort; - return 'default'; - }, [provider, claudeEffort, codexEffort]); - - const composerEffortOptions = useMemo(() => { - const modelOption = providerModelCatalog[provider]?.OPTIONS.find((option) => option.value === currentComposerModel); - if (modelOption?.effort?.values?.length) { - return modelOption.effort.values; - } - - if (provider === 'claude') { - return FALLBACK_COMPOSER_EFFORT_OPTIONS.claude; - } - - if (provider === 'codex') { - return FALLBACK_COMPOSER_EFFORT_OPTIONS.codex; - } - - return []; - }, [providerModelCatalog, provider, currentComposerModel]); - // On WebSocket reconnect, re-fetch the current session's messages from the // server so missed streaming events are shown, then re-subscribe — the // `chat_subscribed` ack restores or clears the activity indicator, replays @@ -413,8 +376,8 @@ function ChatInterface({ onAbortSession={handleAbortSession} permissionMode={permissionMode} onModeSwitch={cyclePermissionMode} - effort={composerEffort} - availableEffortOptions={composerEffortOptions} + effort={currentProviderEffort} + availableEffortOptions={currentProviderEffortOptions} onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)} tokenBudget={tokenBudget} onShowTokenUsage={showCostModal} diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 23cc550e..68a58275 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -1,6 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useMemo } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEvent, ClipboardEvent, @@ -178,7 +177,7 @@ export default function ChatComposer({ left: textareaRect ? textareaRect.left : 16, bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90, }; - }, [input, isCommandMenuOpen, textareaRef]); + }, [isCommandMenuOpen, textareaRef]); // Voice state is hosted here (not in the mic button) so the main Send button can stop // recording and send the transcript in one tap, the way the mic button drops it in the box. @@ -219,16 +218,18 @@ export default function ChatComposer({ const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); setIsEffortDropdownOpen(false); } }; document.addEventListener('pointerdown', handlePointerDown); - document.addEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => { document.removeEventListener('pointerdown', handlePointerDown); - document.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keydown', handleKeyDown, { capture: true }); }; }, [isEffortDropdownOpen]);