feat(x): reasoning-effort control in the chat composer

Adds a Brain chip next to the model picker (Auto / Fast / Balanced /
Thorough) in both the full-screen and side-pane composer. Selection is
per-tab, next-turn intent like the model picker (same ref pattern), but
unlike model it is never frozen on a run — it applies turn by turn,
riding sendConfig.reasoningEffort into turn_created.config.

The chip renders only when the effective model (run-frozen, picked, or
app default) is known-reasoning per the models:list capability flag,
and a stale selection resets when switching to a non-reasoning model.

Turns that ran at a non-auto effort show the level beside the existing
token-usage affordance, read retroactively from the persisted turn
config; reasoning tokens were already displayed there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-10 10:06:44 +05:30
parent 1ffe29db9d
commit 15d4a497f0
5 changed files with 137 additions and 3 deletions

View file

@ -119,6 +119,7 @@ import {
normalizeToolInput,
normalizeToolOutput,
parseAttachedFiles,
REASONING_EFFORT_LABELS,
toToolState,
} from '@/lib/chat-conversation'
import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js'
@ -1430,6 +1431,9 @@ function App() {
const chatViewStateByTabRef = useRef(chatViewStateByTab)
const chatDraftsRef = useRef(new Map<string, string>())
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
// Reasoning effort is per-tab, next-turn intent like the model selection —
// but unlike model it is never frozen on a run; it applies turn by turn.
const reasoningEffortByTabRef = useRef(new Map<string, 'low' | 'medium' | 'high'>())
// Work directory is per-chat. Keyed by tab id; null/absent means none set.
const [workDirByTab, setWorkDirByTab] = useState<Record<string, string | null>>({})
const workDirByTabRef = useRef(workDirByTab)
@ -2972,6 +2976,7 @@ function App() {
// Per-message turn config. Composition inputs land in the system prompt
// via the agent resolver; keep them session-sticky where possible so the
// provider prefix cache survives across turns.
const reasoningEffort = reasoningEffortByTabRef.current.get(submitTabId)
const sendConfig = {
agent: {
agentId,
@ -2989,6 +2994,7 @@ function App() {
},
},
autoPermission: (permissionMode ?? 'manual') === 'auto',
...(reasoningEffort ? { reasoningEffort } : {}),
}
const userMessageContextFor = (middlePane: Awaited<ReturnType<typeof buildMiddlePaneContext>>) => ({
currentDateTime: new Date().toISOString(),
@ -3336,6 +3342,7 @@ function App() {
})
chatDraftsRef.current.delete(tabId)
selectedModelByTabRef.current.delete(tabId)
reasoningEffortByTabRef.current.delete(tabId)
chatScrollTopByTabRef.current.delete(tabId)
setWorkDirByTab((prev) => {
if (!(tabId in prev)) return prev
@ -6033,13 +6040,18 @@ function App() {
if (isTurnUsageMessage(item)) {
return (
<div key={item.id} className="-mt-6 flex justify-start px-1" data-message-id={item.id}>
<div key={item.id} className="-mt-6 flex items-center justify-start gap-1 px-1" data-message-id={item.id}>
<TokenUsageMenu
usage={item.usage}
scope="turn"
modelCallCount={item.modelCallCount}
align="start"
/>
{item.reasoningEffort && (
<span className="text-xs text-muted-foreground/70">
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
</span>
)}
</div>
)
}
@ -6901,6 +6913,13 @@ function App() {
selectedModelByTabRef.current.delete(tab.id)
}
}}
onReasoningEffortChange={(effort) => {
if (effort) {
reasoningEffortByTabRef.current.set(tab.id, effort)
} else {
reasoningEffortByTabRef.current.delete(tab.id)
}
}}
workDir={workDirByTab[tab.id] ?? null}
onWorkDirChange={(v) => setTabWorkDir(tab.id, v)}
isRecording={isActive && isRecording}
@ -6990,6 +7009,13 @@ function App() {
selectedModelByTabRef.current.delete(tabId)
}
}}
onReasoningEffortChangeForTab={(tabId, effort) => {
if (effort) {
reasoningEffortByTabRef.current.set(tabId, effort)
} else {
reasoningEffortByTabRef.current.delete(tabId)
}
}}
workDirByTab={workDirByTab}
onWorkDirChangeForTab={setTabWorkDir}
codeSessionLocks={codeSessionLocks}

View file

@ -17,6 +17,7 @@ import {
Globe,
ImagePlus,
LoaderIcon,
Brain,
Lock,
Mic,
MoreHorizontal,
@ -110,6 +111,16 @@ export interface SelectedModel {
model: string
}
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
// '' = auto (provider default). Ordered as shown in the picker.
const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [
{ value: '', label: 'Auto', hint: 'Provider default' },
{ value: 'low', label: 'Fast', hint: 'Minimal thinking' },
{ value: 'medium', label: 'Balanced', hint: 'Moderate thinking' },
{ value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' },
]
export type PermissionMode = 'manual' | 'auto'
function getSelectedModelDisplayName(model: string) {
@ -254,6 +265,11 @@ interface ChatInputInnerProps {
callAvailable?: boolean
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
onSelectedModelChange?: (model: SelectedModel | null) => void
/**
* Fired when the user picks a reasoning effort (null = auto). Unlike model,
* effort is never frozen on a run it applies per turn.
*/
onReasoningEffortChange?: (effort: ReasoningEffortLevel | null) => void
/** Work directory for this chat (per-chat). Null when none is set. */
workDir?: string | null
/** Fired when the user sets/changes/clears the work directory for this chat. */
@ -290,6 +306,7 @@ function ChatInputInner({
onEndCall,
callAvailable,
onSelectedModelChange,
onReasoningEffortChange,
workDir = null,
onWorkDirChange,
codeSessionLock = null,
@ -309,6 +326,10 @@ function ChatInputInner({
const [defaultModel, setDefaultModel] = useState<ConfiguredModel | null>(null)
const loadModelConfigEpoch = useRef(0)
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
// '' = auto. Per-model reasoning capability ("provider/model" → flag) from
// models:list; the effort control renders only for known-reasoning models.
const [reasoningEffort, setReasoningEffort] = useState<'' | ReasoningEffortLevel>('')
const [reasoningByKey, setReasoningByKey] = useState<Record<string, boolean>>({})
const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
@ -431,12 +452,19 @@ function ChatInputInner({
// catalog (Ollama, OpenAI-compatible) fall back to the models saved in
// config below.
const catalog: Record<string, string[]> = {}
const reasoningFlags: Record<string, boolean> = {}
try {
const listResult = await window.ipc.invoke('models:list', null)
for (const p of listResult.providers || []) {
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
for (const m of p.models || []) {
if (typeof m.reasoning === 'boolean') {
reasoningFlags[`${p.id}/${m.id}`] = m.reasoning
}
}
}
} catch { /* offline / no catalog — fall back to saved config below */ }
if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags)
if (isRowboatConnected) {
for (const m of catalog['rowboat'] || []) push('rowboat', m)
@ -693,6 +721,30 @@ function ChatInputInner({
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
}, [pickerModels, lockedModel, onSelectedModelChange])
// Reasoning effort applies to the model the next message will actually use:
// the run's frozen model once one exists, else the picker selection, else
// the app default. Only known-reasoning models show the control.
const effectiveModelKey = lockedModel
? `${lockedModel.provider}/${lockedModel.model}`
: activeModelKey
|| (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
const reasoningAvailable = reasoningByKey[effectiveModelKey] === true
const handleReasoningEffortChange = useCallback((value: string) => {
const effort = value === 'low' || value === 'medium' || value === 'high' ? value : ''
setReasoningEffort(effort)
onReasoningEffortChange?.(effort === '' ? null : effort)
}, [onReasoningEffortChange])
// Switching to a model without reasoning support drops a stale selection —
// otherwise the next message would carry an effort the model rejects.
useEffect(() => {
if (!reasoningAvailable && reasoningEffort !== '') {
setReasoningEffort('')
onReasoningEffortChange?.(null)
}
}, [reasoningAvailable, reasoningEffort, onReasoningEffortChange])
// Restore the tab draft when this input mounts.
useEffect(() => {
if (initialDraft) {
@ -1284,6 +1336,37 @@ function ChatInputInner({
</DropdownMenu>
)}
<div className="flex-1" />
{reasoningAvailable && (
<DropdownMenu>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Brain className="h-3 w-3 shrink-0" />
{reasoningEffort !== '' && (
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === reasoningEffort)?.label}</span>
)}
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Reasoning effort applies to your next message</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={reasoningEffort} onValueChange={handleReasoningEffortChange}>
{REASONING_EFFORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
<span>{option.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{lockedModel ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
@ -1570,6 +1653,7 @@ export interface ChatInputWithMentionsProps {
onEndCall?: () => void
callAvailable?: boolean
onSelectedModelChange?: (model: SelectedModel | null) => void
onReasoningEffortChange?: (effort: ReasoningEffortLevel | null) => void
workDir?: string | null
onWorkDirChange?: (value: string | null) => void
/** Set when this chat is bound to a Code-section session — freezes workdir + agent. */
@ -1603,6 +1687,7 @@ export function ChatInputWithMentions({
onEndCall,
callAvailable,
onSelectedModelChange,
onReasoningEffortChange,
workDir,
onWorkDirChange,
codeSessionLock,
@ -1633,6 +1718,7 @@ export function ChatInputWithMentions({
onEndCall={onEndCall}
callAvailable={callAvailable}
onSelectedModelChange={onSelectedModelChange}
onReasoningEffortChange={onReasoningEffortChange}
workDir={workDir}
onWorkDirChange={onWorkDirChange}
codeSessionLock={codeSessionLock}

View file

@ -38,7 +38,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
import { defaultRemarkPlugins } from 'streamdown'
import remarkBreaks from 'remark-breaks'
import { type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel, type ReasoningEffortLevel } from '@/components/chat-input-with-mentions'
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { useSidebar } from '@/components/ui/sidebar'
import { wikiLabel } from '@/lib/wiki-links'
@ -63,6 +63,7 @@ import {
normalizeToolInput,
normalizeToolOutput,
parseAttachedFiles,
REASONING_EFFORT_LABELS,
toToolState,
} from '@/lib/chat-conversation'
import { matchBillingError } from '@/lib/billing-error'
@ -162,6 +163,7 @@ interface ChatSidebarProps {
getInitialDraft?: (tabId: string) => string | undefined
onDraftChangeForTab?: (tabId: string, text: string) => void
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
onReasoningEffortChangeForTab?: (tabId: string, effort: ReasoningEffortLevel | null) => void
workDirByTab?: Record<string, string | null>
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
@ -233,6 +235,7 @@ export function ChatSidebar({
getInitialDraft,
onDraftChangeForTab,
onSelectedModelChangeForTab,
onReasoningEffortChangeForTab,
workDirByTab = {},
codeSessionLocks = {},
pinnedToCodeSession = null,
@ -524,13 +527,18 @@ export function ChatSidebar({
if (isTurnUsageMessage(item)) {
return (
<div key={item.id} className="-mt-6 flex justify-start px-1" data-message-id={item.id}>
<div key={item.id} className="-mt-6 flex items-center justify-start gap-1 px-1" data-message-id={item.id}>
<TokenUsageMenu
usage={item.usage}
scope="turn"
modelCallCount={item.modelCallCount}
align="start"
/>
{item.reasoningEffort && (
<span className="text-xs text-muted-foreground/70">
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
</span>
)}
</div>
)
}
@ -840,6 +848,7 @@ export function ChatSidebar({
initialDraft={getInitialDraft?.(tab.id)}
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
onReasoningEffortChange={onReasoningEffortChangeForTab ? (effort) => onReasoningEffortChangeForTab(tab.id, effort) : undefined}
workDir={workDirByTab[tab.id] ?? null}
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null}

View file

@ -53,11 +53,22 @@ export interface ErrorMessage {
timestamp: number
}
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
// User-facing names for the canonical effort ladder ("auto" = absent).
export const REASONING_EFFORT_LABELS: Record<ReasoningEffortLevel, string> = {
low: 'Fast',
medium: 'Balanced',
high: 'Thorough',
}
export interface TurnUsageMessage {
id: string
kind: 'turn-usage'
usage: TokenUsage
modelCallCount: number
// The turn's reasoning effort (from turn_created.config); absent = auto.
reasoningEffort?: ReasoningEffortLevel
timestamp: number
}

View file

@ -340,11 +340,13 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
}
if (hasTokenUsage(state.usage)) {
const reasoningEffort = state.definition.config.reasoningEffort
items.push({
id: `${turnId}:usage`,
kind: 'turn-usage',
usage: state.usage,
modelCallCount: state.modelCalls.filter((call) => call.usage !== undefined).length,
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
timestamp: ts(),
})
}