mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(claude): add effort setting parity with model across stack (#353)
Co-authored-by: Xiaoyi <xiaoyizhang@microsoft.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user