refactor: generic provider effort handling

This commit is contained in:
Simos Mikelatos
2026-06-30 23:56:01 +00:00
parent d618abb075
commit 0206a1f6aa
8 changed files with 188 additions and 164 deletions

View File

@@ -12,11 +12,13 @@
* - WebSocket message streaming * - WebSocket message streaming
*/ */
import { query } from '@anthropic-ai/claude-agent-sdk';
import crypto from 'crypto'; import crypto from 'crypto';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path';
import os from 'os'; 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 { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.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']); const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) { function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) {
const selectedModel = modelsDefinition.OPTIONS const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model) || null;
.find((option) => option.value === model) || null;
const allowedEfforts = selectedModel?.effort?.values const allowedEfforts = selectedModel?.effort?.values
?.map((value) => value.value) || []; ?.map((value) => value.value) || [];
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort) return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)

View File

@@ -91,14 +91,9 @@ const readCodexPriority = (value: unknown): number => (
typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER
); );
const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({ const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => {
value: model.slug as string, const effortValues = Array.isArray(model.supported_reasoning_levels)
label: readOptionalString(model.display_name) ?? (model.slug as string), ? model.supported_reasoning_levels
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) => { .map((level) => {
const value = readOptionalString(level?.effort); const value = readOptionalString(level?.effort);
if (!value) { if (!value) {
@@ -110,10 +105,21 @@ const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({
description: readOptionalString(level?.description), description: readOptionalString(level?.description),
}; };
}) })
.filter((level): level is NonNullable<typeof level> => Boolean(level)), .filter((level): level is NonNullable<typeof level> => 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, : undefined,
}); };
};
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => { const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
const sortedModels = [...models] const sortedModels = [...models]

View File

@@ -21,6 +21,8 @@ type ProviderCapabilities = {
supportsPermissionRequests: boolean; supportsPermissionRequests: boolean;
/** Whether the token-usage endpoint has data for this provider. */ /** Whether the token-usage endpoint has data for this provider. */
supportsTokenUsage: boolean; supportsTokenUsage: boolean;
/** Whether the provider runtime can accept model-level reasoning effort. */
supportsEffort: boolean;
}; };
/** /**
@@ -38,6 +40,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: true, supportsPermissionRequests: true,
supportsTokenUsage: true, supportsTokenUsage: true,
supportsEffort: true,
}, },
cursor: { cursor: {
provider: 'cursor', provider: 'cursor',
@@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: false, supportsPermissionRequests: false,
supportsTokenUsage: false, supportsTokenUsage: false,
supportsEffort: false,
}, },
codex: { codex: {
provider: 'codex', provider: 'codex',
@@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: false, supportsPermissionRequests: false,
supportsTokenUsage: true, supportsTokenUsage: true,
supportsEffort: true,
}, },
gemini: { gemini: {
provider: 'gemini', provider: 'gemini',
@@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: false, supportsPermissionRequests: false,
supportsTokenUsage: true, supportsTokenUsage: true,
supportsEffort: false,
}, },
opencode: { opencode: {
provider: 'opencode', provider: 'opencode',
@@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsAbort: true, supportsAbort: true,
supportsPermissionRequests: false, supportsPermissionRequests: false,
supportsTokenUsage: true, supportsTokenUsage: true,
supportsEffort: false,
}, },
}; };

View File

@@ -0,0 +1,12 @@
import type { LLMProvider, ProviderModelOption } from '../../../types/app';
export const DEFAULT_EFFORT_VALUE = 'default';
export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial<Record<LLMProvider, readonly string[]>> = {
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
codex: ['low', 'medium', 'high', 'xhigh'],
};
export const toProviderEffortOptions = (
values: readonly string[],
): NonNullable<ProviderModelOption['effort']>['values'] => values.map((value) => ({ value }));

View File

@@ -34,11 +34,11 @@ interface UseChatComposerStateArgs {
provider: LLMProvider; provider: LLMProvider;
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
cyclePermissionMode: () => void; cyclePermissionMode: () => void;
resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode;
cursorModel: string; cursorModel: string;
claudeModel: string; claudeModel: string;
codexModel: string; codexModel: string;
claudeEffort: string; currentProviderEffort: string;
codexEffort: string;
geminiModel: string; geminiModel: string;
opencodeModel: string; opencodeModel: string;
isLoading: boolean; isLoading: boolean;
@@ -163,17 +163,6 @@ const getNotificationSessionSummary = (
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback; 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({ export function useChatComposerState({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -181,11 +170,11 @@ export function useChatComposerState({
provider, provider,
permissionMode, permissionMode,
cyclePermissionMode, cyclePermissionMode,
resolvePermissionModeForProvider,
cursorModel, cursorModel,
claudeModel, claudeModel,
codexModel, codexModel,
claudeEffort, currentProviderEffort,
codexEffort,
geminiModel, geminiModel,
opencodeModel, opencodeModel,
isLoading, isLoading,
@@ -745,12 +734,7 @@ export function useChatComposerState({
: provider === 'opencode' : provider === 'opencode'
? opencodeModel ? opencodeModel
: claudeModel; : claudeModel;
const effort = const effort = currentProviderEffort;
provider === 'claude'
? getLatestProviderEffort(provider, claudeEffort)
: provider === 'codex'
? getLatestProviderEffort(provider, codexEffort)
: 'default';
// One message shape for every provider. The backend resolves the // One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the // provider, project path, and provider-native resume id from the
@@ -762,9 +746,7 @@ export function useChatComposerState({
options: { options: {
model, model,
effort, effort,
// Codex has no plan mode; downgrade rather than sending an permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
// unsupported value to its runtime.
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
toolsSettings, toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false, skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary, sessionSummary,
@@ -790,9 +772,8 @@ export function useChatComposerState({
selectedSession, selectedSession,
attachedImages, attachedImages,
claudeModel, claudeModel,
claudeEffort,
codexModel, codexModel,
codexEffort, currentProviderEffort,
currentSessionId, currentSessionId,
cursorModel, cursorModel,
executeCommand, executeCommand,
@@ -803,6 +784,7 @@ export function useChatComposerState({
onSessionEstablished, onSessionEstablished,
permissionMode, permissionMode,
provider, provider,
resolvePermissionModeForProvider,
resetCommandMenuState, resetCommandMenuState,
scrollToBottom, scrollToBottom,
selectedProject, selectedProject,

View File

@@ -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 { authenticatedFetch } from '../../../utils/api';
import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { import type {
@@ -9,6 +10,11 @@ import type {
ProviderModelsCacheInfo, ProviderModelsCacheInfo,
ProviderModelsDefinition, ProviderModelsDefinition,
} from '../../../types/app'; } from '../../../types/app';
import {
DEFAULT_EFFORT_VALUE,
FALLBACK_PROVIDER_EFFORT_VALUES,
toProviderEffortOptions,
} from '../constants/providerEffort';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = { const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'default', claude: 'default',
@@ -18,12 +24,7 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
opencode: 'anthropic/claude-sonnet-4-5', opencode: 'anthropic/claude-sonnet-4-5',
}; };
const DEFAULT_EFFORT_VALUE = 'default'; const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const FALLBACK_EFFORT_VALUES: Partial<Record<LLMProvider, string[]>> = {
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
codex: ['low', 'medium', 'high', 'xhigh'],
};
/** /**
* Fallback permission-mode matrix used only until the backend capability * Fallback permission-mode matrix used only until the backend capability
@@ -47,6 +48,7 @@ type ProviderCapabilities = {
supportsAbort: boolean; supportsAbort: boolean;
supportsPermissionRequests: boolean; supportsPermissionRequests: boolean;
supportsTokenUsage: boolean; supportsTokenUsage: boolean;
supportsEffort?: boolean;
}; };
type ProviderCapabilitiesApiResponse = { 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<PermissionMode>('default'); const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]); const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<LLMProvider>(() => { const [provider, setProvider] = useState<LLMProvider>(() => {
@@ -95,11 +97,11 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [codexModel, setCodexModel] = useState<string>(() => { const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
}); });
const [claudeEffort, setClaudeEffort] = useState<string>(() => { const [providerEfforts, setProviderEfforts] = useState<Partial<Record<LLMProvider, string>>>(() => {
return localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE; return PROVIDERS.reduce<Partial<Record<LLMProvider, string>>>((acc, targetProvider) => {
}); acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE;
const [codexEffort, setCodexEffort] = useState<string>(() => { return acc;
return localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE; }, {});
}); });
const [geminiModel, setGeminiModel] = useState<string>(() => { const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini; 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) => { const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => {
if (targetProvider === 'claude') { setProviderEfforts((previous) => (
setClaudeEffort(effort); previous[targetProvider] === effort
localStorage.setItem('claude-effort', effort); ? previous
return; : { ...previous, [targetProvider]: effort }
} ));
localStorage.setItem(`${targetProvider}-effort`, effort);
if (targetProvider === 'codex') {
setCodexEffort(effort);
localStorage.setItem('codex-effort', effort);
}
}, []); }, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => { const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const requestId = providerModelsRequestIdRef.current + 1; const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId; providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true; const isHardRefresh = options.bypassCache === true;
@@ -186,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
try { try {
const results = await Promise.all( const results = await Promise.all(
providers.map(async (p) => { PROVIDERS.map(async (p) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options.bypassCache) { if (options.bypassCache) {
params.set('bypassCache', 'true'); params.set('bypassCache', 'true');
@@ -210,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {}; const nextCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>> = {};
const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {}; const nextCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>> = {};
providers.forEach((p, i) => { PROVIDERS.forEach((p, i) => {
const entry = results[i]; const entry = results[i];
if (!entry) { if (!entry) {
return; return;
@@ -271,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default']; return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]); }, [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 = ( const pickStoredOrCurrent = (
storageKey: string, storageKey: string,
current: string, current: string,
@@ -302,9 +316,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
targetProvider: LLMProvider, targetProvider: LLMProvider,
model: string, model: string,
): string[] => { ): string[] => {
if (!getSupportsEffortForProvider(targetProvider)) {
return [];
}
const option = getModelOption(targetProvider, model); const option = getModelOption(targetProvider, model);
return option?.effort?.values.map((value) => value.value) ?? FALLBACK_EFFORT_VALUES[targetProvider] ?? []; if (option) {
}, [getModelOption]); return option.effort?.values.map((value) => value.value) ?? [];
}
return [...(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? [])];
}, [getModelOption, getSupportsEffortForProvider]);
const reconcileStoredEffort = useCallback(( const reconcileStoredEffort = useCallback((
targetProvider: LLMProvider, targetProvider: LLMProvider,
@@ -316,16 +338,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return DEFAULT_EFFORT_VALUE; return DEFAULT_EFFORT_VALUE;
} }
const storageKey = `${targetProvider}-effort`; if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) {
const storedEffort = localStorage.getItem(storageKey);
if (storedEffort === DEFAULT_EFFORT_VALUE || storedEffort === null) {
return DEFAULT_EFFORT_VALUE; return DEFAULT_EFFORT_VALUE;
} }
if (allowedValues.includes(storedEffort)) {
return storedEffort;
}
if (allowedValues.includes(currentEffort)) { if (allowedValues.includes(currentEffort)) {
return currentEffort; return currentEffort;
} }
@@ -333,6 +349,14 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return DEFAULT_EFFORT_VALUE; return DEFAULT_EFFORT_VALUE;
}, [getAllowedEffortValues]); }, [getAllowedEffortValues]);
const providerModels = useMemo<Record<LLMProvider, string>>(() => ({
claude: claudeModel,
cursor: cursorModel,
codex: codexModel,
gemini: geminiModel,
opencode: opencodeModel,
}), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]);
useEffect(() => { useEffect(() => {
const claude = providerModelCatalog.claude; const claude = providerModelCatalog.claude;
if (claude) { if (claude) {
@@ -346,16 +370,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
} }
}, [providerModelCatalog.claude, claudeModel]); }, [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(() => { useEffect(() => {
const cursor = providerModelCatalog.cursor; const cursor = providerModelCatalog.cursor;
if (cursor) { if (cursor) {
@@ -382,16 +396,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
} }
}, [providerModelCatalog.codex, codexModel]); }, [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(() => { useEffect(() => {
const gemini = providerModelCatalog.gemini; const gemini = providerModelCatalog.gemini;
if (gemini) { if (gemini) {
@@ -418,6 +422,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
} }
}, [providerModelCatalog.opencode, opencodeModel]); }, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const nextEfforts: Partial<Record<LLMProvider, string>> = {};
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(() => { useEffect(() => {
if (!selectedSession?.id) { if (!selectedSession?.id) {
return; return;
@@ -425,8 +450,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider); const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default'); setPermissionMode(
}, [selectedSession?.id, provider, getPermissionModesForProvider]); savedMode && validModes.includes(savedMode)
? savedMode
: getDefaultPermissionModeForProvider(provider),
);
}, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]);
useEffect(() => { useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) { if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -480,6 +509,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
} }
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]); }, [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 ( const selectProviderModel = useCallback(async (
targetProvider: LLMProvider, targetProvider: LLMProvider,
model: string, model: string,
@@ -515,6 +554,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}; };
}, [setStoredProviderModel]); }, [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 { return {
provider, provider,
setProvider, setProvider,
@@ -524,10 +577,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setClaudeModel, setClaudeModel,
codexModel, codexModel,
setCodexModel, setCodexModel,
claudeEffort, currentProviderEffort,
setClaudeEffort, currentProviderEffortOptions,
codexEffort,
setCodexEffort,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel, opencodeModel,
@@ -544,5 +595,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }), hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
selectProviderModel, selectProviderModel,
setStoredProviderEffort, setStoredProviderEffort,
resolvePermissionModeForProvider,
}; };
} }

View File

@@ -16,11 +16,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer'; import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal'; 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({ function ChatInterface({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -75,10 +70,8 @@ function ChatInterface({
setClaudeModel, setClaudeModel,
codexModel, codexModel,
setCodexModel, setCodexModel,
claudeEffort, currentProviderEffort,
setClaudeEffort, currentProviderEffortOptions,
codexEffort,
setCodexEffort,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel, opencodeModel,
@@ -94,6 +87,7 @@ function ChatInterface({
hardRefreshProviderModels, hardRefreshProviderModels,
selectProviderModel, selectProviderModel,
setStoredProviderEffort, setStoredProviderEffort,
resolvePermissionModeForProvider,
} = useChatProviderState({ } = useChatProviderState({
selectedSession, selectedSession,
selectedProject, selectedProject,
@@ -208,8 +202,7 @@ function ChatInterface({
cursorModel, cursorModel,
claudeModel, claudeModel,
codexModel, codexModel,
claudeEffort, currentProviderEffort,
codexEffort,
geminiModel, geminiModel,
opencodeModel, opencodeModel,
isLoading: isProcessing, isLoading: isProcessing,
@@ -226,39 +219,9 @@ function ChatInterface({
addMessage, addMessage,
setIsUserScrolledUp, setIsUserScrolledUp,
setPendingPermissionRequests, 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 // On WebSocket reconnect, re-fetch the current session's messages from the
// server so missed streaming events are shown, then re-subscribe — the // server so missed streaming events are shown, then re-subscribe — the
// `chat_subscribed` ack restores or clears the activity indicator, replays // `chat_subscribed` ack restores or clears the activity indicator, replays
@@ -413,8 +376,8 @@ function ChatInterface({
onAbortSession={handleAbortSession} onAbortSession={handleAbortSession}
permissionMode={permissionMode} permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode} onModeSwitch={cyclePermissionMode}
effort={composerEffort} effort={currentProviderEffort}
availableEffortOptions={composerEffortOptions} availableEffortOptions={currentProviderEffortOptions}
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)} onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
tokenBudget={tokenBudget} tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal} onShowTokenUsage={showCostModal}

View File

@@ -1,6 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { import type {
ChangeEvent, ChangeEvent,
ClipboardEvent, ClipboardEvent,
@@ -178,7 +177,7 @@ export default function ChatComposer({
left: textareaRect ? textareaRect.left : 16, left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90, 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 // 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. // 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) => { const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setIsEffortDropdownOpen(false); setIsEffortDropdownOpen(false);
} }
}; };
document.addEventListener('pointerdown', handlePointerDown); document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => { return () => {
document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown, { capture: true });
}; };
}, [isEffortDropdownOpen]); }, [isEffortDropdownOpen]);