From 15d4a497f00479cd98fc3ac37077a9ec05089d26 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:06:44 +0530 Subject: [PATCH] feat(x): reasoning-effort control in the chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/x/apps/renderer/src/App.tsx | 28 +++++- .../components/chat-input-with-mentions.tsx | 86 +++++++++++++++++++ .../renderer/src/components/chat-sidebar.tsx | 13 ++- .../renderer/src/lib/chat-conversation.ts | 11 +++ .../src/lib/session-chat/turn-view.ts | 2 + 5 files changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 560fa90b..0ffe61bd 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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()) const selectedModelByTabRef = useRef(new Map()) + // 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()) // Work directory is per-chat. Keyed by tab id; null/absent means none set. const [workDirByTab, setWorkDirByTab] = useState>({}) 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>) => ({ 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 ( -
+
+ {item.reasoningEffort && ( + + {REASONING_EFFORT_LABELS[item.reasoningEffort]} + + )}
) } @@ -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} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 223bfac8..15ddcd48 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -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(null) const loadModelConfigEpoch = useRef(0) const [lockedModel, setLockedModel] = useState(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>({}) 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 = {} + const reasoningFlags: Record = {} 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({ )}
+ {reasoningAvailable && ( + + + + + + + + Reasoning effort — applies to your next message + + + + {REASONING_EFFORT_OPTIONS.map((option) => ( + + {option.label} + {option.hint} + + ))} + + + + )} {lockedModel ? ( @@ -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} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 025a638d..2f013d96 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -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 /** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */ codeSessionLocks?: Record @@ -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 ( -
+
+ {item.reasoningEffort && ( + + {REASONING_EFFORT_LABELS[item.reasoningEffort]} + + )}
) } @@ -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} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 6397ded2..b9185af3 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -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 = { + 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 } diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts index f5d46295..bb697e60 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts @@ -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(), }) }