Skip to content
106 changes: 69 additions & 37 deletions src/browser/components/ThinkingSelector/ThinkingSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
} from "@/browser/utils/fastModeServiceTier";
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { cn } from "@/common/lib/utils";
import assert from "@/common/utils/assert";
import {
getThinkingDisplayLabel,
THINKING_LEVELS,
type OpenAIReasoningMode,
type ThinkingLevel,
} from "@/common/types/thinking";
Expand All @@ -38,22 +40,26 @@ const THINKING_OPTION_LABELS: Record<ThinkingLevel, string> = {

// Friendly option label, provider-aware for xhigh/max so menu rows match the
// trigger's branding (e.g. Opus 4.6 calls its top effort "Max", not "Extra High").
function getThinkingMenuLabel(level: ThinkingLevel, capabilityModel: string): string {
if (level === "xhigh" || level === "max") {
function getThinkingMenuLabel(level: ThinkingLevel, capabilityModel?: string): string {
if (capabilityModel && (level === "xhigh" || level === "max")) {
return getThinkingDisplayLabel(level, capabilityModel) === "XHIGH" ? "Extra High" : "Max";
}
return THINKING_OPTION_LABELS[level];
}

export interface ThinkingInheritOption {
/** Row/trigger label shown while inherit is selected (e.g. "Inherit from UI Exec"). */
/** Row/trigger label shown while inherit is selected. */
label: string;
selected: boolean;
onSelect: () => void;
}

interface ThinkingSelectorControlProps {
modelString: string;
modelString: string | undefined;
/** Delegated preferences may inherit a model that is only known at launch. */
modelCapabilitiesDeferred?: boolean;
/** Independent of effort/model inheritance; false denotes an explicit mode override. */
reasoningModeInherited?: boolean;
thinkingLevel: ThinkingLevel;
onThinkingLevelChange: (level: ThinkingLevel) => void;
reasoningMode: OpenAIReasoningMode;
Expand Down Expand Up @@ -87,35 +93,53 @@ export const ThinkingSelectorControl: React.FC<ThinkingSelectorControlProps> = (
const variant = props.variant ?? "composer";
const inheritSelected = props.inheritOption?.selected === true;

// Resolve mapped aliases so the selector offers the target model's ladder
// (e.g. an alias mapped to GPT-5.6 exposes native max).
const minimum = getMinimum(props.modelString);
const allowed = getAvailableThinkingLevels(props.modelString, minimum, providersConfig);
const effectiveThinkingLevel = enforceThinkingPolicy(
props.modelString,
props.thinkingLevel,
minimum,
providersConfig
);
// Label from the capability model so mapped aliases (e.g. xai:team-grok ->
// xai:grok-4.6) show the same provider-aware xhigh/max wording as the ladder.
const capabilityModel = resolveModelForMetadata(props.modelString, providersConfig ?? null);
const resolvedRoute = routing.resolveRoute(normalizeToCanonical(props.modelString)).route;
const proModeAvailable =
props.allowProMode !== false &&
openaiProModeAvailable(props.modelString, {
providersConfig,
resolvedRouteProvider: resolvedRoute,
});
const fastModeProvider =
props.allowFastMode !== false && providersConfig != null
? getFastModeProvider(props.modelString, {
providersConfig,
resolvedRouteProvider: resolvedRoute,
})
: null;
const modelCapabilitiesDeferred = props.modelCapabilitiesDeferred ?? false;
const reasoningModeInherited = props.reasoningModeInherited === true;
const { allowed, effectiveThinkingLevel, capabilityModel, proModeAvailable, fastModeProvider } =
(() => {
// The calling chat's model is unknown in Settings. Store preferences without
// borrowing global Exec capabilities; the launch path enforces the real model's policy.
if (modelCapabilitiesDeferred) {
return {
allowed: THINKING_LEVELS,
effectiveThinkingLevel: props.thinkingLevel,
capabilityModel: undefined,
proModeAvailable: props.allowProMode !== false,
fastModeProvider: null,
};
}

assert(props.modelString, "A model is required unless capabilities are deferred");
const minimum = getMinimum(props.modelString);
const resolvedRoute = routing.resolveRoute(normalizeToCanonical(props.modelString)).route;
return {
allowed: getAvailableThinkingLevels(props.modelString, minimum, providersConfig),
effectiveThinkingLevel: enforceThinkingPolicy(
props.modelString,
props.thinkingLevel,
minimum,
providersConfig
),
// Mapped aliases use the target model's ladder and provider-aware labels.
capabilityModel: resolveModelForMetadata(props.modelString, providersConfig ?? null),
proModeAvailable:
props.allowProMode !== false &&
openaiProModeAvailable(props.modelString, {
providersConfig,
resolvedRouteProvider: resolvedRoute,
}),
fastModeProvider:
props.allowFastMode !== false && providersConfig != null
? getFastModeProvider(props.modelString, {
providersConfig,
resolvedRouteProvider: resolvedRoute,
})
: null,
};
})();
const fastModeAvailable = fastModeProvider != null;
const proModeActive = proModeAvailable && props.reasoningMode === "pro";
const proModeActive =
!reasoningModeInherited && proModeAvailable && props.reasoningMode === "pro";
const fastModeActive =
fastModeProvider != null && providersConfig?.[fastModeProvider]?.serviceTier === "priority";
const hasMenu =
Expand Down Expand Up @@ -195,7 +219,9 @@ export const ThinkingSelectorControl: React.FC<ThinkingSelectorControlProps> = (
onClick={() => setIsOpen((previous) => !previous)}
>
<span data-thinking-label className="min-w-[3ch] text-center">
{getThinkingDisplayLabel(effectiveThinkingLevel, capabilityModel)}
{modelCapabilitiesDeferred
? THINKING_OPTION_LABELS[effectiveThinkingLevel]
: getThinkingDisplayLabel(effectiveThinkingLevel, capabilityModel)}
</span>
{proModeActive && (
<span
Expand Down Expand Up @@ -239,12 +265,12 @@ export const ThinkingSelectorControl: React.FC<ThinkingSelectorControlProps> = (
? props.inheritOption.label
: getThinkingMenuLabel(effectiveThinkingLevel, capabilityModel)}
</span>
{proModeActive && (
{(proModeActive || props.reasoningModeInherited === false) && (
<span
data-thinking-pro-status
className="border-border-medium text-muted rounded-[3px] border bg-transparent px-1 text-[9px] leading-[14px] font-semibold tracking-wide"
>
PRO
{props.reasoningMode === "pro" ? "PRO" : "STANDARD"}
</span>
)}
{fastModeActive && (
Expand Down Expand Up @@ -297,6 +323,10 @@ export const ThinkingSelectorControl: React.FC<ThinkingSelectorControlProps> = (
trigger
)}

{reasoningModeInherited && (
<div className="text-muted mt-1 text-xs">Reasoning mode: Use calling chat’s Exec</div>
)}

{isOpen && (
<div
// Box menus open downward inside a scrollable settings pane; scroll the
Expand Down Expand Up @@ -358,14 +388,16 @@ export const ThinkingSelectorControl: React.FC<ThinkingSelectorControlProps> = (
<button
type="button"
data-component="ProModeToggle"
aria-pressed={proModeActive}
aria-pressed={reasoningModeInherited ? "mixed" : proModeActive}
className="hover:bg-hover flex w-full items-center gap-2.5 px-2.5 py-1.5 text-left transition-colors"
onClick={() => props.onReasoningModeChange(proModeActive ? "standard" : "pro")}
>
<span className="min-w-0 flex-1">
<span className="text-foreground block text-[11px] font-medium">Pro mode</span>
<span className="text-muted block text-[10px] font-normal">
More reliable on difficult tasks
{reasoningModeInherited
? "Use calling chat’s Exec"
: "More reliable on difficult tasks"}
</span>
</span>
{proModeActive && <Check className="text-thinking-mode h-3 w-3" aria-hidden />}
Expand Down
62 changes: 18 additions & 44 deletions src/browser/features/Settings/Sections/TasksSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import {
} from "@/common/types/tasks";
import {
coerceThinkingLevel,
getThinkingOptionLabel,
THINKING_LEVEL_OFF,
type OpenAIReasoningMode,
type ThinkingLevel,
Expand Down Expand Up @@ -306,16 +305,14 @@ interface AiDefaultsControlsProps {
modelValue: string;
thinkingValue: string;
reasoningModeValue: OpenAIReasoningMode;
reasoningModeInherited?: boolean;
modelCapabilitiesDeferred?: boolean;
/** Forwarded to the picker; false hides the Pro toggle (e.g. Dream, whose requests never apply reasoningMode). */
allowProMode?: boolean;
effectiveModel: string;
effectiveModel: string | undefined;
models: string[];
hiddenModelsForSelector: string[];
inheritLabel?: string;
resetModelLabel?: string;
resetThinkingLabel?: string;
inheritedModelDescription?: string;
inheritedThinkingDescription?: string;
showThinkingResetButton?: boolean;
onModelChange: (value: string) => void;
onThinkingChange: (value: string) => void;
Expand All @@ -324,8 +321,6 @@ interface AiDefaultsControlsProps {

function AiDefaultsControls(props: AiDefaultsControlsProps) {
const inheritLabel = props.inheritLabel ?? "Inherit";
const resetModelLabel = props.resetModelLabel ?? "Reset";
const resetThinkingLabel = props.resetThinkingLabel ?? "Reset";

return (
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
Expand All @@ -350,13 +345,10 @@ function AiDefaultsControls(props: AiDefaultsControlsProps) {
className="h-9 px-2"
onClick={() => props.onModelChange(INHERIT)}
>
{resetModelLabel}
Reset
</Button>
) : null}
</div>
{props.modelValue === INHERIT && props.inheritedModelDescription ? (
<div className="text-muted text-xs">{props.inheritedModelDescription}</div>
) : null}
</div>

<div className="space-y-1">
Expand All @@ -366,9 +358,11 @@ function AiDefaultsControls(props: AiDefaultsControlsProps) {
(route-aware Pro mode, provider Fast mode) as the chat input. */}
<ThinkingSelectorControl
modelString={props.effectiveModel}
modelCapabilitiesDeferred={props.modelCapabilitiesDeferred}
thinkingLevel={coerceThinkingLevel(props.thinkingValue) ?? THINKING_LEVEL_OFF}
onThinkingLevelChange={(level) => props.onThinkingChange(level)}
reasoningMode={props.reasoningModeValue}
reasoningModeInherited={props.reasoningModeInherited}
onReasoningModeChange={props.onReasoningModeChange}
allowProMode={props.allowProMode}
variant="box"
Expand All @@ -386,13 +380,10 @@ function AiDefaultsControls(props: AiDefaultsControlsProps) {
className="h-9 px-2"
onClick={() => props.onThinkingChange(INHERIT)}
>
{resetThinkingLabel}
Reset
</Button>
) : null}
</div>
{props.thinkingValue === INHERIT && props.inheritedThinkingDescription ? (
<div className="text-muted text-xs">{props.inheritedThinkingDescription}</div>
) : null}
</div>
</div>
);
Expand Down Expand Up @@ -791,20 +782,11 @@ export function TasksSection() {
};

const setSubagentReasoningMode = (agentId: string, mode: OpenAIReasoningMode) => {
// Deleting the override falls back to the base (interactive) profile, so
// when that profile is pro, turning the toggle off must persist an explicit
// "standard" or the inherited pro would win and the toggle could never
// disable it. Delete (sparse storage) only when nothing pro is inherited.
const inheritsPro = agentAiDefaults[agentId]?.reasoningMode === "pro";
// The calling chat is unknown here: Standard must remain explicit even if
// global Exec is Standard, or a Pro calling chat would override the choice.
setAgentAiDefaults((prev) =>
updateAgentSubagentProfile(prev, agentId, (profile) => {
if (mode === "pro") {
profile.reasoningMode = "pro";
} else if (inheritsPro) {
profile.reasoningMode = "standard";
} else {
delete profile.reasoningMode;
}
profile.reasoningMode = mode;
})
);
};
Expand Down Expand Up @@ -1060,13 +1042,6 @@ export function TasksSection() {
const entry = agentAiDefaults.exec?.subagent;
const modelValue = entry?.modelString ?? INHERIT;
const thinkingValue = entry?.thinkingLevel ?? INHERIT;
const uiExecEntry = agentAiDefaults.exec;
const inheritedExecModel =
uiExecEntry?.modelString ?? resolveDefinitionModel(agent) ?? inheritedEffectiveModel;
const effectiveModel = modelValue !== INHERIT ? modelValue : inheritedExecModel;
const rawInheritedThinking = uiExecEntry?.thinkingLevel ?? THINKING_LEVEL_OFF;
const clampedInheritedThinking = enforceThinkingPolicy(effectiveModel, rawInheritedThinking);
const inheritedThinkingLabel = getThinkingOptionLabel(clampedInheritedThinking, effectiveModel);

return (
<div
Expand All @@ -1081,23 +1056,22 @@ export function TasksSection() {
{agent.id} • {agent.scope} • {renderPolicySummary(agent)}
</div>
<div className="text-muted mt-1 text-xs">
Unset fields inherit from UI Exec defaults. Enabled and advisor settings stay shared
with UI Exec.
Unset fields use the calling chat’s Exec settings at task creation, falling back to UI
Exec defaults when no chat selection exists. Model capabilities are enforced at launch.
Enabled and advisor settings stay shared with UI Exec.
</div>
</div>

<AiDefaultsControls
modelValue={modelValue}
thinkingValue={thinkingValue}
reasoningModeValue={entry?.reasoningMode ?? uiExecEntry?.reasoningMode ?? "standard"}
effectiveModel={effectiveModel}
reasoningModeValue={entry?.reasoningMode ?? "standard"}
reasoningModeInherited={entry?.reasoningMode === undefined}
modelCapabilitiesDeferred={entry?.modelString === undefined}
effectiveModel={entry?.modelString}
models={models}
hiddenModelsForSelector={hiddenModelsForSelector}
inheritLabel="Inherit from UI Exec"
resetModelLabel="Inherit from UI Exec"
resetThinkingLabel="Inherit from UI Exec"
inheritedModelDescription={`Inherits from UI Exec: ${inheritedExecModel}`}
inheritedThinkingDescription={`Inherits from UI Exec: ${inheritedThinkingLabel}`}
inheritLabel="Use calling chat’s Exec"
showThinkingResetButton
onModelChange={(value) => setSubagentModel("exec", value)}
onThinkingChange={(value) => setSubagentThinking("exec", value)}
Expand Down
Loading
Loading