mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
1ffe29db9d
commit
15d4a497f0
5 changed files with 137 additions and 3 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue