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
+10 -2
View File
@@ -327,6 +327,13 @@ export class ApiClient {
})
}
async setEffort(sessionId: string, effort: string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/effort`, {
method: 'POST',
body: JSON.stringify({ effort })
})
}
async approvePermission(
sessionId: string,
requestId: string,
@@ -384,11 +391,12 @@ export class ApiClient {
modelReasoningEffort?: string,
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string
worktreeName?: string,
effort?: string
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName })
body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort })
})
}
@@ -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}
@@ -18,6 +18,7 @@ export function useSessionActions(
setPermissionMode: (mode: PermissionMode) => Promise<void>
setCollaborationMode: (mode: CodexCollaborationMode) => Promise<void>
setModel: (model: string | null) => Promise<void>
setEffort: (effort: string | null) => Promise<void>
renameSession: (name: string) => Promise<void>
deleteSession: () => Promise<void>
isPending: boolean
@@ -99,6 +100,16 @@ export function useSessionActions(
onSuccess: () => void invalidateSession(),
})
const effortMutation = useMutation({
mutationFn: async (effort: string | null) => {
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
await api.setEffort(sessionId, effort)
},
onSuccess: () => void invalidateSession(),
})
const renameMutation = useMutation({
mutationFn: async (name: string) => {
if (!api || !sessionId) {
@@ -131,6 +142,7 @@ export function useSessionActions(
setPermissionMode: permissionMutation.mutateAsync,
setCollaborationMode: collaborationMutation.mutateAsync,
setModel: modelMutation.mutateAsync,
setEffort: effortMutation.mutateAsync,
renameSession: renameMutation.mutateAsync,
deleteSession: deleteMutation.mutateAsync,
isPending: abortMutation.isPending
@@ -139,6 +151,7 @@ export function useSessionActions(
|| permissionMutation.isPending
|| collaborationMutation.isPending
|| modelMutation.isPending
|| effortMutation.isPending
|| renameMutation.isPending
|| deleteMutation.isPending,
}
+3 -1
View File
@@ -8,6 +8,7 @@ type SpawnInput = {
directory: string
agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
model?: string
effort?: string
modelReasoningEffort?: string
yolo?: boolean
sessionType?: 'simple' | 'worktree'
@@ -34,7 +35,8 @@ export function useSpawnSession(api: ApiClient | null): {
input.modelReasoningEffort,
input.yolo,
input.sessionType,
input.worktreeName
input.worktreeName,
input.effort
)
},
onSuccess: () => {
+8 -3
View File
@@ -30,7 +30,7 @@ const RECONNECT_MAX_DELAY_MS = 30_000
const RECONNECT_JITTER_MS = 500
const INVALIDATION_BATCH_MS = 16
type SessionPatch = Partial<Pick<Session, 'active' | 'thinking' | 'activeAt' | 'updatedAt' | 'model' | 'permissionMode' | 'collaborationMode'>>
type SessionPatch = Partial<Pick<Session, 'active' | 'thinking' | 'activeAt' | 'updatedAt' | 'model' | 'effort' | 'permissionMode' | 'collaborationMode'>>
function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number {
if (left.active !== right.active) {
@@ -85,6 +85,10 @@ function getSessionPatch(value: unknown): SessionPatch | null {
patch.model = value.model
hasKnownPatch = true
}
if (value.effort === null || typeof value.effort === 'string') {
patch.effort = value.effort
hasKnownPatch = true
}
if (typeof value.permissionMode === 'string') {
patch.permissionMode = value.permissionMode as Session['permissionMode']
hasKnownPatch = true
@@ -101,7 +105,7 @@ function hasUnknownSessionPatchKeys(value: unknown): boolean {
if (!hasRecordShape(value)) {
return false
}
const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'permissionMode', 'collaborationMode'])
const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'effort', 'permissionMode', 'collaborationMode'])
return Object.keys(value).some((key) => !knownKeys.has(key))
}
@@ -386,7 +390,8 @@ export function useSSE(options: {
thinking: patch.thinking ?? current.thinking,
activeAt: patch.activeAt ?? current.activeAt,
updatedAt: patch.updatedAt ?? current.updatedAt,
model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model
model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model,
effort: Object.prototype.hasOwnProperty.call(patch, 'effort') ? patch.effort ?? null : current.effort
}
patched = true
+2
View File
@@ -103,6 +103,7 @@ export default {
'newSession.type.worktree.placeholder': 'feature-x (default 1228-xxxx)',
'newSession.agent': 'Agent',
'newSession.model': 'Model',
'newSession.effort': 'Effort',
'newSession.model.optional': 'optional',
'newSession.reasoningEffort': 'Reasoning effort',
'newSession.yolo': 'YOLO mode',
@@ -274,6 +275,7 @@ export default {
'misc.collaborationMode': 'Collaboration Mode',
'misc.permissionMode': 'Permission Mode',
'misc.model': 'Model',
'misc.effort': 'Effort',
'misc.loading': 'Loading…',
'misc.loadOlder': 'Load older',
'misc.newMessage': '{n} new message{s}',
+2
View File
@@ -105,6 +105,7 @@ export default {
'newSession.type.worktree.placeholder': 'feature-x (默认 1228-xxxx)',
'newSession.agent': '代理',
'newSession.model': '模型',
'newSession.effort': '思考强度',
'newSession.model.optional': '可选',
'newSession.reasoningEffort': '推理强度',
'newSession.yolo': 'YOLO 模式',
@@ -276,6 +277,7 @@ export default {
'misc.collaborationMode': '协作模式',
'misc.permissionMode': '权限模式',
'misc.model': '模型',
'misc.effort': '思考强度',
'misc.loading': '加载中…',
'misc.loadOlder': '加载更早的',
'misc.newMessage': '{n} 条新消息',