feat(claude): add effort setting parity with model across stack (#353)

Co-authored-by: Xiaoyi <xiaoyizhang@microsoft.com>
This commit is contained in:
xyzhang626
2026-03-24 21:15:48 +08:00
committed by GitHub
co-authored by Xiaoyi
parent 30265fdc25
commit a200fe9628
50 changed files with 715 additions and 52 deletions
@@ -29,6 +29,7 @@ import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem'
import { useTranslation } from '@/lib/use-translation'
import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions'
import { getClaudeComposerEffortOptions } from './claudeEffortOptions'
export interface TextInputState {
text: string
@@ -42,6 +43,7 @@ export function HappyComposer(props: {
permissionMode?: PermissionMode
collaborationMode?: CodexCollaborationMode
model?: string | null
effort?: string | null
active?: boolean
allowSendWhenInactive?: boolean
thinking?: boolean
@@ -52,6 +54,7 @@ export function HappyComposer(props: {
onCollaborationModeChange?: (mode: CodexCollaborationMode) => void
onPermissionModeChange?: (mode: PermissionMode) => void
onModelChange?: (model: string | null) => void
onEffortChange?: (effort: string | null) => void
onSwitchToRemote?: () => void
onTerminal?: () => void
terminalUnsupported?: boolean
@@ -69,6 +72,7 @@ export function HappyComposer(props: {
permissionMode: rawPermissionMode,
collaborationMode: rawCollaborationMode,
model: rawModel,
effort: rawEffort,
active = true,
allowSendWhenInactive = false,
thinking = false,
@@ -79,6 +83,7 @@ export function HappyComposer(props: {
onCollaborationModeChange,
onPermissionModeChange,
onModelChange,
onEffortChange,
onSwitchToRemote,
onTerminal,
terminalUnsupported = false,
@@ -94,6 +99,7 @@ export function HappyComposer(props: {
const permissionMode = rawPermissionMode ?? 'default'
const collaborationMode = rawCollaborationMode ?? 'default'
const model = rawModel ?? null
const effort = rawEffort ?? null
const api = useAssistantApi()
const composerText = useAssistantState(({ composer }) => composer.text)
@@ -263,6 +269,10 @@ export function HappyComposer(props: {
() => getClaudeComposerModelOptions(model),
[model]
)
const claudeEffortOptions = useMemo(
() => getClaudeComposerEffortOptions(effort),
[effort]
)
const permissionModes = useMemo(
() => permissionModeOptions.map((option) => option.mode),
[permissionModeOptions]
@@ -420,10 +430,18 @@ export function HappyComposer(props: {
haptic('light')
}, [onModelChange, controlsDisabled, haptic])
const handleEffortChange = useCallback((nextEffort: string | null) => {
if (!onEffortChange || controlsDisabled) return
onEffortChange(nextEffort)
setShowSettings(false)
haptic('light')
}, [onEffortChange, controlsDisabled, haptic])
const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0)
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0)
const showModelSettings = Boolean(onModelChange && isClaudeFlavor(agentFlavor))
const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings)
const showEffortSettings = Boolean(onEffortChange && isClaudeFlavor(agentFlavor))
const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)
const showAbortButton = true
const voiceEnabled = Boolean(onVoiceToggle)
@@ -432,7 +450,7 @@ export function HappyComposer(props: {
}, [api])
const overlays = useMemo(() => {
if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings)) {
if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)) {
return (
<div className="absolute bottom-[100%] mb-2 w-full">
<FloatingOverlay maxHeight={320}>
@@ -473,7 +491,7 @@ export function HappyComposer(props: {
</div>
) : null}
{showCollaborationSettings && (showPermissionSettings || showModelSettings) ? (
{showCollaborationSettings && (showPermissionSettings || showModelSettings || showEffortSettings) ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
@@ -514,7 +532,7 @@ export function HappyComposer(props: {
</div>
) : null}
{(showCollaborationSettings || showPermissionSettings) && showModelSettings ? (
{(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showEffortSettings) ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
@@ -554,6 +572,47 @@ export function HappyComposer(props: {
))}
</div>
) : null}
{showModelSettings && showEffortSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showEffortSettings ? (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.effort')}
</div>
{claudeEffortOptions.map((option) => (
<button
key={option.value ?? 'auto'}
type="button"
disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
controlsDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => handleEffortChange(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
effort === option.value
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{effort === option.value && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={effort === option.value ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
))}
</div>
) : null}
</FloatingOverlay>
</div>
)
@@ -579,18 +638,22 @@ export function HappyComposer(props: {
showCollaborationSettings,
showPermissionSettings,
showModelSettings,
showEffortSettings,
claudeModelOptions,
claudeEffortOptions,
suggestions,
selectedIndex,
controlsDisabled,
collaborationMode,
permissionMode,
model,
effort,
collaborationModeOptions,
permissionModeOptions,
handleCollaborationChange,
handlePermissionChange,
handleModelChange,
handleEffortChange,
handleSuggestionSelect,
t
])
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { getClaudeComposerEffortOptions } from './claudeEffortOptions'
describe('getClaudeComposerEffortOptions', () => {
it('includes the active non-preset Claude effort in the options list', () => {
expect(getClaudeComposerEffortOptions('ultra')).toEqual([
{ value: null, label: 'Auto' },
{ value: 'ultra', label: 'Ultra' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
])
})
it('does not duplicate preset Claude effort values', () => {
expect(getClaudeComposerEffortOptions('high')).toEqual([
{ value: null, label: 'Auto' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
])
})
})
@@ -0,0 +1,49 @@
export type ClaudeComposerEffortOption = {
value: string | null
label: string
}
const CLAUDE_EFFORT_PRESETS = ['medium', 'high', 'max'] as const
const CLAUDE_EFFORT_LABELS: Record<(typeof CLAUDE_EFFORT_PRESETS)[number], string> = {
medium: 'Medium',
high: 'High',
max: 'Max'
}
function normalizeClaudeComposerEffort(effort?: string | null): string | null {
const trimmedEffort = effort?.trim().toLowerCase()
if (!trimmedEffort || trimmedEffort === 'auto' || trimmedEffort === 'default') {
return null
}
return trimmedEffort
}
function formatEffortLabel(effort: string): string {
return CLAUDE_EFFORT_LABELS[effort as keyof typeof CLAUDE_EFFORT_LABELS]
?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`
}
export function getClaudeComposerEffortOptions(currentEffort?: string | null): ClaudeComposerEffortOption[] {
const normalizedCurrentEffort = normalizeClaudeComposerEffort(currentEffort)
const options: ClaudeComposerEffortOption[] = [
{ value: null, label: 'Auto' }
]
if (
normalizedCurrentEffort
&& !CLAUDE_EFFORT_PRESETS.includes(normalizedCurrentEffort as typeof CLAUDE_EFFORT_PRESETS[number])
) {
options.push({
value: normalizedCurrentEffort,
label: formatEffortLabel(normalizedCurrentEffort)
})
}
options.push(...CLAUDE_EFFORT_PRESETS.map((effort) => ({
value: effort,
label: CLAUDE_EFFORT_LABELS[effort]
})))
return options
}
@@ -0,0 +1,37 @@
import type { AgentType, ClaudeEffort } from './types'
import { CLAUDE_EFFORT_OPTIONS } from './types'
import { useTranslation } from '@/lib/use-translation'
export function ClaudeEffortSelector(props: {
agent: AgentType
effort: ClaudeEffort
isDisabled: boolean
onEffortChange: (value: ClaudeEffort) => void
}) {
const { t } = useTranslation()
if (props.agent !== 'claude') {
return null
}
return (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.effort')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
value={props.effort}
onChange={(e) => props.onEffortChange(e.target.value as ClaudeEffort)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{CLAUDE_EFFORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
+12 -1
View File
@@ -9,12 +9,13 @@ import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggesti
import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions'
import { useRecentPaths } from '@/hooks/useRecentPaths'
import { useTranslation } from '@/lib/use-translation'
import type { AgentType, CodexReasoningEffort, SessionType } from './types'
import type { AgentType, ClaudeEffort, CodexReasoningEffort, SessionType } from './types'
import { ActionButtons } from './ActionButtons'
import { AgentSelector } from './AgentSelector'
import { DirectorySection } from './DirectorySection'
import { MachineSelector } from './MachineSelector'
import { ModelSelector } from './ModelSelector'
import { ClaudeEffortSelector } from './ClaudeEffortSelector'
import { ReasoningEffortSelector } from './ReasoningEffortSelector'
import {
loadPreferredAgent,
@@ -46,6 +47,7 @@ export function NewSession(props: {
const [isDirectoryFocused, setIsDirectoryFocused] = useState(false)
const [agent, setAgent] = useState<AgentType>(loadPreferredAgent)
const [model, setModel] = useState('auto')
const [effort, setEffort] = useState<ClaudeEffort>('auto')
const [modelReasoningEffort, setModelReasoningEffort] = useState<CodexReasoningEffort>('default')
const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode)
const [sessionType, setSessionType] = useState<SessionType>('simple')
@@ -62,6 +64,7 @@ export function NewSession(props: {
useEffect(() => {
setModel('auto')
setEffort('auto')
}, [agent])
useEffect(() => {
@@ -244,6 +247,7 @@ export function NewSession(props: {
}
const resolvedModel = model !== 'auto' && agent !== 'opencode' ? model : undefined
const resolvedEffort = agent === 'claude' && effort !== 'auto' ? effort : undefined
const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default'
? modelReasoningEffort
: undefined
@@ -252,6 +256,7 @@ export function NewSession(props: {
directory: trimmedDirectory,
agent,
model: resolvedModel,
effort: resolvedEffort,
modelReasoningEffort: resolvedModelReasoningEffort,
yolo: yoloMode,
sessionType,
@@ -324,6 +329,12 @@ export function NewSession(props: {
isDisabled={isFormDisabled}
onModelChange={setModel}
/>
<ClaudeEffortSelector
agent={agent}
effort={effort}
isDisabled={isFormDisabled}
onEffortChange={setEffort}
/>
<ReasoningEffortSelector
agent={agent}
value={modelReasoningEffort}
+12 -1
View File
@@ -1,6 +1,6 @@
import { CLAUDE_MODEL_PRESETS, getClaudeModelLabel } from '@hapi/protocol'
import { describe, expect, it } from 'vitest'
import { MODEL_OPTIONS } from './types'
import { CLAUDE_EFFORT_OPTIONS, MODEL_OPTIONS } from './types'
describe('Claude model options', () => {
it('includes 1m model options in the expected order', () => {
@@ -19,3 +19,14 @@ describe('Claude model options', () => {
expect(getClaudeModelLabel('opus[1m]')).toBe('Opus 1M')
})
})
describe('Claude effort options', () => {
it('matches supported effort presets in expected order', () => {
expect(CLAUDE_EFFORT_OPTIONS).toEqual([
{ value: 'auto', label: 'Auto' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
])
})
})
+8
View File
@@ -1,6 +1,7 @@
export type AgentType = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
export type SessionType = 'simple' | 'worktree'
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh'
export type ClaudeEffort = 'auto' | 'medium' | 'high' | 'max'
export const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
claude: [
@@ -36,3 +37,10 @@ export const CODEX_REASONING_EFFORT_OPTIONS: { value: CodexReasoningEffort; labe
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' },
]
export const CLAUDE_EFFORT_OPTIONS: { value: ClaudeEffort; label: string }[] = [
{ value: 'auto', label: 'Auto' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
]
+14 -1
View File
@@ -50,7 +50,7 @@ export function SessionChat(props: {
const agentFlavor = props.session.metadata?.flavor ?? null
const controlledByUser = props.session.agentState?.controlledByUser === true
const codexCollaborationModeSupported = agentFlavor === 'codex' && !controlledByUser
const { abortSession, switchSession, setPermissionMode, setCollaborationMode, setModel } = useSessionActions(
const { abortSession, switchSession, setPermissionMode, setCollaborationMode, setModel, setEffort } = useSessionActions(
props.api,
props.session.id,
agentFlavor,
@@ -233,6 +233,17 @@ export function SessionChat(props: {
}
}, [setModel, props.onRefresh, haptic])
const handleEffortChange = useCallback(async (effort: string | null) => {
try {
await setEffort(effort)
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set effort:', e)
}
}, [setEffort, props.onRefresh, haptic])
// Abort handler
const handleAbort = useCallback(async () => {
await abortSession()
@@ -332,6 +343,7 @@ export function SessionChat(props: {
permissionMode={props.session.permissionMode}
collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined}
model={props.session.model}
effort={props.session.effort}
agentFlavor={agentFlavor}
active={props.session.active}
allowSendWhenInactive
@@ -346,6 +358,7 @@ export function SessionChat(props: {
}
onPermissionModeChange={handlePermissionModeChange}
onModelChange={handleModelChange}
onEffortChange={handleEffortChange}
onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined}
terminalUnsupported={props.session.active && !terminalSupported}