FN-7768: inline thinking-level control in CustomModelDropdown and migrate task/agent surfaces

Adds an inline thinking-level selector to CustomModelDropdown and migrates existing model-selection surfaces (task form, quick entry, new agent dialog, model selection modal/tab) to use it instead of their own bespoke thinking-level controls.

- CustomModelDropdown: add inline thinking-level control (CSS + component logic)
- ModelSelectionModal / ModelSelectorTab: drop local thinking-level UI in favor of the dropdown's inline control
- NewAgentDialog: migrate to the shared inline thinking-level control
- QuickEntryBox: remove now-redundant thinking-level UI, delegate to CustomModelDropdown
- TaskForm: migrate to the shared inline thinking-level control
- Add/update tests for CustomModelDropdown, ModelSelectionModal, ModelSelectorTab, NewAgentDialog, QuickEntryBox, TaskForm
- Add changeset for @runfusion/fusion (feature)
- Update dashboard guide docs

Files changed:
 .changeset/fn-7768-inline-thinking-dropdown.md     |  7 ++
 docs/dashboard-guide.md                            |  1 +
 .../app/components/CustomModelDropdown.css         | 33 +++++++++
 .../app/components/CustomModelDropdown.tsx         | 69 ++++++++++++++++-
 .../app/components/ModelSelectionModal.tsx         | 32 +-------
 .../dashboard/app/components/ModelSelectorTab.tsx  | 33 +--------
 .../dashboard/app/components/NewAgentDialog.tsx    | 18 +----
 .../dashboard/app/components/QuickEntryBox.tsx     | 59 +--------------
 packages/dashboard/app/components/TaskForm.tsx     | 23 +-----
 .../__tests__/CustomModelDropdown.test.tsx         | 72 ++++++++++++++++++
 .../__tests__/ModelSelectionModal.test.tsx         | 48 +++++++++---
 .../components/__tests__/ModelSelectorTab.test.tsx |  3 +-
 .../components/__tests__/NewAgentDialog.test.tsx   | 86 ++++++++++++++++++++++
 .../components/__tests__/QuickEntryBox.test.tsx    | 42 +++++++----
 .../app/components/__tests__/TaskForm.test.tsx     |  7 +-
 15 files changed, 354 insertions(+), 179 deletions(-)

Fusion-Task-Id: FN-7768
Fusion-Task-Lineage: 8f32731b-9865-4902-9420-62d3c0295805
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 00:48:14 -07:00
parent 167067c5b0
commit 3cda9d830a
15 changed files with 354 additions and 179 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add inline thinking-level selection to task and agent model dropdowns.
category: feature
dev: CustomModelDropdown now supports optional thinking-level props; migrated task and agent surfaces off standalone selects.

View File

@@ -480,6 +480,7 @@ Rules:
- `auto-new` creates a branch after task creation using `fusion/{task-id}-{short-name}` (for example `fusion/fn-5671-branch-strategy-dropdown`).
- `Merge target / base branch` stays optional for all modes and uses the same branch-dropdown + `Custom…` fallback behavior as Planning Mode.
- In **More options → Model Configuration**, **Auto-merge** is a per-task override with three states: **Default** (follow project setting), **Enabled**, or **Disabled**.
- In **More options → Model Configuration**, task and agent model pickers expose **Thinking Level** inside the same model dropdown panel instead of as a separate adjacent selector. Task pickers offer **Default (project setting)** plus **Off**, **Minimal**, **Low**, **Medium**, **High**, and **Very High**; agent creation is concrete-only and starts at **Off**.
- In **More options → Model Configuration**, **Planner oversight** is a per-task override of the workflow-native `plannerOversightLevel` setting (FN-7508): **Inherit from workflow** (default) plus **Off**, **Observe**, **Steer**, and **Autonomous recovery**. This selector appears in both the New Task dialog and the Task Detail edit form (same shared control). Selecting **Inherit from workflow** clears the per-task override (sent as `null` on edit, omitted on create) so the task falls back to the effective `plannerOversightLevel` configured on its workflow — set project/global defaults for this in the **Workflow Editor → Values** tab, not in Project Settings; it is workflow-native, not a project setting.
The dialog also exposes AI handoffs that quick-add no longer shows: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior for Subtask. **Execution mode** and optional workflow-step selection are available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution and opt into workflow-specific creation-time steps before creating a task from either surface.

View File

@@ -38,6 +38,7 @@
.model-combobox-trigger-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -130,6 +131,38 @@
border-bottom: 1px solid var(--border);
}
.model-combobox-thinking {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.model-combobox-thinking-label {
flex: 1;
min-width: 0;
color: var(--text-muted);
font-size: var(--font-size-sm);
font-weight: 600;
}
.model-combobox-thinking-select {
flex: 1;
min-width: 0;
}
.model-combobox-thinking-badge {
flex-shrink: 0;
margin-left: var(--space-sm);
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
FNXC:ModelDropdown 2026-07-09-00:00:
FN-7760 requires the portaled model list to remain the touch-scroll owner on mobile. The global mobile lockdown keeps body/root overflow hidden and defaults touch gestures to vertical panning, so this fixed-position scroller needs its own iOS momentum scrolling, contained overscroll, and explicit vertical pan contract.

View File

@@ -1,5 +1,6 @@
import "./CustomModelDropdown.css";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef, useId } from "react";
import { THINKING_LEVELS } from "@fusion/core";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import type { ModelInfo } from "../api";
@@ -30,6 +31,14 @@ export interface CustomModelDropdownProps {
onToggleModelFavorite?: (modelId: string) => void;
/** Request a wider menu for dense settings surfaces while default callers keep trigger-width sizing. */
menuWidth?: "trigger" | "readable";
/** Optional thinking/reasoning effort value; empty string means inherit/default when defaultThinkingLevel is provided. */
thinkingLevel?: string;
/** Called when the optional inline thinking-level selector changes. */
onThinkingLevelChange?: (level: string) => void;
/** Effective default thinking level; when supplied, the selector includes an empty "Default (level)" option. */
defaultThinkingLevel?: string;
/** Explicitly render the optional inline thinking-level selector even without a change callback. */
showThinkingLevel?: boolean;
}
interface DropdownPosition {
@@ -68,6 +77,10 @@ export function CustomModelDropdown({
noChangeLabel: noChangeLabelProp,
defaultOptionLabel: defaultOptionLabelProp,
menuWidth = "trigger",
thinkingLevel,
onThinkingLevelChange,
defaultThinkingLevel,
showThinkingLevel,
}: CustomModelDropdownProps) {
const { t } = useTranslation("app");
const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…");
@@ -78,6 +91,7 @@ export function CustomModelDropdown({
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const generatedThinkingId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
@@ -137,6 +151,30 @@ export function CustomModelDropdown({
}, [modelsByProvider, favoriteProviders]);
const hasNoChangeOption = typeof noChangeValue === "string" && noChangeValue.length > 0;
const shouldShowThinking = showThinkingLevel ?? Boolean(onThinkingLevelChange);
const normalizedThinkingLevel = thinkingLevel ?? "";
const hasDefaultThinkingOption = typeof defaultThinkingLevel === "string";
const thinkingSelectId = id ? `${id}-thinking-level` : `${generatedThinkingId}-thinking-level`;
/*
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
The shared model dropdown can optionally embed a thinking-level selector so task and agent model pickers expose one consistent reasoning-effort affordance, including `xhigh`. The selector stays inert unless a caller opts in with `showThinkingLevel` or `onThinkingLevelChange`, preserving every settings, insights, schedule, workflow, planning, onboarding, and bulk-edit surface that only needs model selection.
*/
const thinkingOptions = useMemo(() => THINKING_LEVELS.map((level) => ({
value: level,
label: t(`models.options.${level}`, level === "xhigh" ? "Very High" : level.charAt(0).toUpperCase() + level.slice(1)),
})), [t]);
const thinkingBadgeLabel = useMemo(() => {
if (!shouldShowThinking) return "";
if (normalizedThinkingLevel) {
return thinkingOptions.find((option) => option.value === normalizedThinkingLevel)?.label ?? normalizedThinkingLevel;
}
if (hasDefaultThinkingOption) {
return t("modelSelection.thinkingDefault", "Default ({{level}})", { level: defaultThinkingLevel });
}
return thinkingOptions.find((option) => option.value === "off")?.label ?? "Off";
}, [defaultThinkingLevel, hasDefaultThinkingOption, normalizedThinkingLevel, shouldShowThinking, t, thinkingOptions]);
// Get current provider from value
const currentProvider = useMemo(() => {
@@ -530,6 +568,30 @@ export function CustomModelDropdown({
{t("models.count", { count: filteredModels.length, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })}
</div>
{shouldShowThinking && (
<div className="model-combobox-thinking" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<label className="model-combobox-thinking-label" htmlFor={thinkingSelectId}>
{t("models.labels.thinkingLevel", "Thinking Level")}
</label>
<select
id={thinkingSelectId}
className="thinking-level-select model-combobox-thinking-select"
data-testid="custom-model-dropdown-thinking"
value={normalizedThinkingLevel}
onChange={(e) => onThinkingLevelChange?.(e.target.value)}
disabled={disabled || !onThinkingLevelChange}
aria-label={t("models.labels.thinkingLevel", "Thinking Level")}
>
{hasDefaultThinkingOption && (
<option value="">{t("modelSelection.thinkingDefault", "Default ({{level}})", { level: defaultThinkingLevel })}</option>
)}
{thinkingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
)}
<div ref={listRef} className="model-combobox-list">
{specialOptions.map((option, index) => (
<div
@@ -691,6 +753,11 @@ export function CustomModelDropdown({
</span>
)}
<span className="model-combobox-trigger-text">{selectedDisplayText || placeholder}</span>
{shouldShowThinking && (
<span className={`model-badge ${normalizedThinkingLevel ? "model-badge-custom" : "model-badge-default"} model-combobox-thinking-badge`} data-testid="custom-model-dropdown-thinking-badge">
{thinkingBadgeLabel}
</span>
)}
<span className="model-combobox-trigger-arrow">▼</span>
</button>
</div>

View File

@@ -296,6 +296,9 @@ export function ModelSelectionModal({
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={onThinkingLevelChange}
defaultThinkingLevel={defaultThinkingLevel ?? "off"}
/>
</div>
</div>
@@ -326,35 +329,6 @@ export function ModelSelectionModal({
</div>
</div>
{onThinkingLevelChange && (
<div className="task-detail-section">
<div className="inline-create-model-row">
<label htmlFor="model-selection-thinking" className="inline-create-model-label">
{t("modelSelection.thinkingModel", "Thinking")}
</label>
<span
className={`model-badge ${thinkingLevel ? "model-badge-custom" : "model-badge-default"}`}
data-testid="thinking-badge"
>
{thinkingLevel || t("modelSelection.usingDefault", "Using default")}
</span>
<select
id="model-selection-thinking"
data-testid="model-selection-thinking"
value={thinkingLevel}
onChange={(e) => onThinkingLevelChange(e.target.value)}
>
<option value="">{t("modelSelection.thinkingDefault", "Default ({{level}})", { level: defaultThinkingLevel ?? "off" })}</option>
<option value="off">{t("models.options.off", "Off")}</option>
<option value="minimal">{t("models.options.minimal", "Minimal")}</option>
<option value="low">{t("models.options.low", "Low")}</option>
<option value="medium">{t("models.options.medium", "Medium")}</option>
<option value="high">{t("models.options.high", "High")}</option>
<option value="xhigh">{t("models.options.xhigh", "Very High")}</option>
</select>
</div>
</div>
)}
</div>
</div>

View File

@@ -415,6 +415,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
thinkingLevel={selectedThinking ?? ""}
onThinkingLevelChange={handleThinkingChange}
defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
/>
<small>{t("models.descriptions.executor", "The AI model used to implement this task.")}</small>
</div>
@@ -479,36 +482,6 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
<small>{t("models.descriptions.planning", "The AI model used for task specification (triage phase).")}</small>
</div>
<div className="form-group">
<label htmlFor="thinkingLevel">{t("models.labels.thinkingLevel", "Thinking Level")}</label>
<div className="model-selector-current">
{savedThinking === null ? (
<span className="model-badge model-badge-default">
{t("models.states.usingDefault", "Using default")} ({settings?.defaultThinkingLevel ?? "off"})
</span>
) : (
<span className="model-badge model-badge-custom">
{savedThinking}
</span>
)}
</div>
<select
id="thinkingLevel"
value={selectedThinking ?? ""}
onChange={(e) => handleThinkingChange(e.target.value)}
disabled={isSaving}
className="thinking-level-select"
>
<option value="">{t("models.options.default", "Default")} ({settings?.defaultThinkingLevel ?? "off"})</option>
<option value="off">{t("models.options.off", "Off")}</option>
<option value="minimal">{t("models.options.minimal", "Minimal")}</option>
<option value="low">{t("models.options.low", "Low")}</option>
<option value="medium">{t("models.options.medium", "Medium")}</option>
<option value="high">{t("models.options.high", "High")}</option>
<option value="xhigh">{t("models.options.xhigh", "Very High")}</option>
</select>
<small>{t("models.descriptions.thinkingLevel", "Controls the reasoning effort for the AI agent. Higher levels use more tokens.")}</small>
</div>
<div className="model-selector-status">
{executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === null

View File

@@ -369,6 +369,8 @@ export function NewAgentDialog({
onToggleFavorite={toggleFavoriteProvider}
favoriteModels={favoriteModels}
onToggleModelFavorite={toggleFavoriteModel}
thinkingLevel={runtimeConfig.thinkingLevel}
onThinkingLevelChange={(level) => setRuntimeConfig(c => ({ ...c, thinkingLevel: level as ThinkingLevel }))}
/>
)}
</div>
@@ -674,22 +676,6 @@ export function NewAgentDialog({
{step === 1 && (
<div>
{renderRuntimeSourceSection("agent-runtime-source-step-1")}
<div className="agent-dialog-field">
<label htmlFor="agent-thinking">{t("agents.fieldThinkingLevel", "Thinking Level")}</label>
<select
id="agent-thinking"
className="select"
value={runtimeConfig.thinkingLevel}
onChange={e => setRuntimeConfig(c => ({ ...c, thinkingLevel: e.target.value as ThinkingLevel }))}
>
<option value="off">{t("agents.thinkingOff", "Off")}</option>
<option value="minimal">{t("agents.thinkingMinimal", "Minimal")}</option>
<option value="low">{t("agents.thinkingLow", "Low")}</option>
<option value="medium">{t("agents.thinkingMedium", "Medium")}</option>
<option value="high">{t("agents.thinkingHigh", "High")}</option>
<option value="xhigh">{t("agents.thinkingXhigh", "Very High")}</option>
</select>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-max-turns">{t("agents.fieldMaxTurns", "Max Turns")}</label>
<input

View File

@@ -170,7 +170,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [showPriorityPicker, setShowPriorityPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | "thinking" | null>(null);
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
@@ -415,7 +415,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
const hasPlanningOverride = Boolean(planningProvider && planningModelId);
const hasThinkingOverride = Boolean(thinkingLevel);
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride) + Number(hasPlanningOverride);
const modelMenuLabel = selectedPresetId
? settings?.modelPresets?.find((p) => p.id === selectedPresetId)?.name ?? t("tasks.models", "Models")
@@ -2351,59 +2350,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
{/*
FNXC:Settings-ThinkingLevel 2026-07-09-00:00:
Quick-entry inline model menu must expose the same thinking (reasoning-effort) levels as the full task
pickers (TaskForm, ModelSelectorTab, ModelSelectionModal) so a task created from this bar can carry a
per-task thinking-level override just like one created from the New Task modal.
*/}
<button
type="button"
className={`model-menu-item ${hasThinkingOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("thinking")}
data-testid="model-menu-thinking"
>
<span className="model-menu-item-label">
<Brain size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
{t("tasks.modelThinking", "Thinking")}
</span>
<span className="model-menu-item-value">
{hasThinkingOverride
? thinkingLevel
: t("tasks.usingDefault", "Using default")}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
</div>
) : activeModelSubmenu === "thinking" ? (
// Submenu with a plain <select> for the thinking-level override (not a model list)
<div className="model-submenu">
<button
type="button"
className="model-submenu-back"
onClick={() => setActiveModelSubmenu(null)}
data-testid="model-submenu-back"
>
<ChevronDown size={12} style={{ transform: "rotate(90deg)", marginRight: 4 }} />
{t("common.back", "Back")}
</button>
<div className="model-submenu-header">
{t("tasks.thinkingModel", "Thinking Level")}
</div>
<select
id="model-thinking-select"
data-testid="model-thinking-select"
value={thinkingLevel}
onChange={(e) => handleThinkingLevelChange(e.target.value)}
>
<option value="">{t("modelSelection.thinkingDefault", "Default ({{level}})", { level: settings?.defaultThinkingLevel ?? "off" })}</option>
<option value="off">{t("models.options.off", "Off")}</option>
<option value="minimal">{t("models.options.minimal", "Minimal")}</option>
<option value="low">{t("models.options.low", "Low")}</option>
<option value="medium">{t("models.options.medium", "Medium")}</option>
<option value="high">{t("models.options.high", "High")}</option>
<option value="xhigh">{t("models.options.xhigh", "Very High")}</option>
</select>
</div>
) : (
// Submenu with CustomModelDropdown for the selected target
@@ -2446,6 +2392,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
onToggleFavorite={handleToggleFavorite}
favoriteModels={effectiveFavoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
thinkingLevel={activeModelSubmenu === "executor" ? thinkingLevel : undefined}
onThinkingLevelChange={activeModelSubmenu === "executor" ? handleThinkingLevelChange : undefined}
defaultThinkingLevel={activeModelSubmenu === "executor" ? settings?.defaultThinkingLevel ?? "off" : undefined}
/>
{modelsError && (
<div className="model-submenu-error">

View File

@@ -1527,6 +1527,9 @@ export function TaskForm({
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
thinkingLevel={thinkingLevel || ""}
onThinkingLevelChange={onThinkingLevelChange ? (value) => onThinkingLevelChange(value) : undefined}
defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
/>
</div>
<div className="model-select-row">
@@ -1569,26 +1572,6 @@ export function TaskForm({
/>
</div>
)}
{onThinkingLevelChange && (
<div className="model-select-row">
{/* FNXC:Settings-ThinkingLevel 2026-06-19-14:55: The shared task thinking selector must expose `xhigh` so new-task and task-detail edits can request maximum reasoning effort instead of being capped at `high`. */}
<label htmlFor="thinking-level" className="model-select-label">{t("taskForm.thinkingLabel", "Thinking")}</label>
<select
id="thinking-level"
value={thinkingLevel || ""}
onChange={(e) => onThinkingLevelChange(e.target.value)}
disabled={disabled || presetMode === "preset"}
>
<option value="">{t("taskForm.thinkingDefault", "Default ({{level}})", { level: settings?.defaultThinkingLevel ?? "off" })}</option>
<option value="off">{t("taskForm.thinkingOff", "Off")}</option>
<option value="minimal">{t("taskForm.thinkingMinimal", "Minimal")}</option>
<option value="low">{t("taskForm.thinkingLow", "Low")}</option>
<option value="medium">{t("taskForm.thinkingMedium", "Medium")}</option>
<option value="high">{t("taskForm.thinkingHigh", "High")}</option>
<option value="xhigh">{t("taskForm.thinkingXhigh", "Very High")}</option>
</select>
</div>
)}
{onPlannerOversightLevelChange && (
<div className="model-select-row">
{/* FNXC:PlannerOversight 2026-07-04-00:00: Per-task override for the workflow-native plannerOversightLevel setting (FN-7508). Empty value inherits the workflow's effective value; the four levels mirror BUILTIN_OVERSIGHT_SETTINGS verbatim. Configuration only — runtime controls are FN-7517. */}

View File

@@ -50,6 +50,78 @@ describe("CustomModelDropdown", () => {
expect(css).not.toMatch(/(^|\n)\s*html\s*\*/);
});
it("renders opt-in thinking control with default option and calls back for concrete and inherited values", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onThinkingLevelChange = vi.fn();
render(
<CustomModelDropdown
label="Executor Model"
value="openai/gpt-4o"
onChange={onChange}
models={MOCK_MODELS}
thinkingLevel="high"
onThinkingLevelChange={onThinkingLevelChange}
defaultThinkingLevel="off"
/>,
);
expect(screen.getByTestId("custom-model-dropdown-thinking-badge")).toHaveTextContent("High");
await user.click(screen.getByRole("button", { name: "Executor Model" }));
const thinkingSelect = await screen.findByTestId("custom-model-dropdown-thinking");
expect(thinkingSelect).toHaveAccessibleName("Thinking Level");
expect(within(thinkingSelect).getByRole("option", { name: "Default (off)" })).toBeTruthy();
for (const optionName of ["Off", "Minimal", "Low", "Medium", "High", "Very High"]) {
expect(within(thinkingSelect).getByRole("option", { name: optionName })).toBeTruthy();
}
await user.selectOptions(thinkingSelect, "xhigh");
expect(onThinkingLevelChange).toHaveBeenLastCalledWith("xhigh");
await user.selectOptions(thinkingSelect, "");
expect(onThinkingLevelChange).toHaveBeenLastCalledWith("");
});
it("renders concrete-only thinking control without Default when no defaultThinkingLevel is supplied", async () => {
const user = userEvent.setup();
render(
<CustomModelDropdown
label="Agent Model"
value=""
onChange={vi.fn()}
models={MOCK_MODELS}
thinkingLevel="off"
onThinkingLevelChange={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Agent Model" }));
const thinkingSelect = await screen.findByTestId("custom-model-dropdown-thinking");
expect(within(thinkingSelect).queryByRole("option", { name: /Default/ })).toBeNull();
expect(within(thinkingSelect).getAllByRole("option")).toHaveLength(6);
});
it("keeps thinking control inert when callers do not opt in", async () => {
const user = userEvent.setup();
render(
<CustomModelDropdown
label="Settings Model"
value=""
onChange={vi.fn()}
models={MOCK_MODELS}
/>,
);
expect(screen.queryByTestId("custom-model-dropdown-thinking-badge")).toBeNull();
await user.click(screen.getByRole("button", { name: "Settings Model" }));
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
});
it("renders the open dropdown in a portal attached to document.body", async () => {
const user = userEvent.setup();
const onChange = vi.fn();

View File

@@ -68,6 +68,9 @@ vi.mock("../CustomModelDropdown", () => ({
onChange,
models,
placeholder,
thinkingLevel,
onThinkingLevelChange,
defaultThinkingLevel,
}: {
id: string;
label: string;
@@ -75,10 +78,33 @@ vi.mock("../CustomModelDropdown", () => ({
onChange: (value: string) => void;
models: ModelInfo[];
placeholder: string;
thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void;
defaultThinkingLevel?: string;
}) => (
<div data-testid={`mock-dropdown-${id}`}>
<span data-testid={`dropdown-label-${id}`}>{label}</span>
<span data-testid={`dropdown-value-${id}`}>{value || "empty"}</span>
{onThinkingLevelChange ? (
<>
<span data-testid="custom-model-dropdown-thinking-badge" className={`model-badge ${thinkingLevel ? "model-badge-custom" : "model-badge-default"}`}>
{thinkingLevel || "Default"}
</span>
<select
data-testid="custom-model-dropdown-thinking"
value={thinkingLevel || ""}
onChange={(e) => onThinkingLevelChange(e.target.value)}
>
<option value="">Default ({defaultThinkingLevel ?? "off"})</option>
<option value="off">Off</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Very High</option>
</select>
</>
) : null}
<select
data-testid={`dropdown-select-${id}`}
value={value}
@@ -280,14 +306,14 @@ describe("ModelSelectionModal", () => {
describe("Thinking level selection", () => {
it("does not render the thinking-level selector when onThinkingLevelChange is omitted", () => {
renderModelSelectionModal();
expect(screen.queryByTestId("model-selection-thinking")).toBeNull();
expect(screen.queryByTestId("thinking-badge")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-thinking-badge")).toBeNull();
});
it("renders the thinking-level selector with all six levels plus Default when onThinkingLevelChange is provided", () => {
renderModelSelectionModal({ onThinkingLevelChange: vi.fn() });
const select = screen.getByTestId("model-selection-thinking") as HTMLSelectElement;
const select = screen.getByTestId("custom-model-dropdown-thinking") as HTMLSelectElement;
expect(select).toBeTruthy();
const options = Array.from(select.options).map((o) => o.value);
@@ -296,24 +322,24 @@ describe("ModelSelectionModal", () => {
it("still omits the thinking-level selector while models are empty, even when onThinkingLevelChange is provided", () => {
renderModelSelectionModal({ models: [], onThinkingLevelChange: vi.fn() });
expect(screen.queryByTestId("model-selection-thinking")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
});
it("still omits the thinking-level selector while models are loading", () => {
renderModelSelectionModal({ modelsLoading: true, onThinkingLevelChange: vi.fn() });
expect(screen.queryByTestId("model-selection-thinking")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
});
it("still omits the thinking-level selector when models fail to load", () => {
renderModelSelectionModal({ modelsError: "Failed to fetch", onThinkingLevelChange: vi.fn() });
expect(screen.queryByTestId("model-selection-thinking")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-thinking")).toBeNull();
});
it("calls onThinkingLevelChange with the selected level", () => {
const onThinkingLevelChange = vi.fn();
renderModelSelectionModal({ onThinkingLevelChange });
const select = screen.getByTestId("model-selection-thinking");
const select = screen.getByTestId("custom-model-dropdown-thinking");
fireEvent.change(select, { target: { value: "high" } });
expect(onThinkingLevelChange).toHaveBeenCalledWith("high");
@@ -323,7 +349,7 @@ describe("ModelSelectionModal", () => {
const onThinkingLevelChange = vi.fn();
renderModelSelectionModal({ onThinkingLevelChange, thinkingLevel: "high" });
const select = screen.getByTestId("model-selection-thinking");
const select = screen.getByTestId("custom-model-dropdown-thinking");
fireEvent.change(select, { target: { value: "" } });
expect(onThinkingLevelChange).toHaveBeenCalledWith("");
@@ -332,15 +358,15 @@ describe("ModelSelectionModal", () => {
it("displays the thinking badge as 'Using default' when thinkingLevel is empty", () => {
renderModelSelectionModal({ onThinkingLevelChange: vi.fn(), thinkingLevel: "" });
const badge = screen.getByTestId("thinking-badge");
expect(badge.textContent).toBe("Using default");
const badge = screen.getByTestId("custom-model-dropdown-thinking-badge");
expect(badge.textContent).toBe("Default");
expect(badge.classList.contains("model-badge-default")).toBe(true);
});
it("displays the thinking badge with the selected level when overridden", () => {
renderModelSelectionModal({ onThinkingLevelChange: vi.fn(), thinkingLevel: "xhigh" });
const badge = screen.getByTestId("thinking-badge");
const badge = screen.getByTestId("custom-model-dropdown-thinking-badge");
expect(badge.textContent).toBe("xhigh");
expect(badge.classList.contains("model-badge-custom")).toBe(true);
});

View File

@@ -168,7 +168,8 @@ describe("ModelSelectorTab", () => {
}, "project-alpha");
});
await user.selectOptions(screen.getByLabelText("Thinking Level"), "high");
await user.click(screen.getByRole("button", { name: "Executor Model" }));
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(4, "FN-7398", {
thinkingLevel: "high",

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { NewAgentDialog } from "../NewAgentDialog";
vi.mock("../api", async () => {
const actual = await vi.importActual<typeof import("../api")>("../api");
return {
...actual,
fetchModels: vi.fn().mockResolvedValue({
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchPluginRuntimes: vi.fn().mockResolvedValue({ runtimes: [] }),
createAgent: vi.fn().mockResolvedValue({ id: "agent-1" }),
};
});
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({
thinkingLevel,
onThinkingLevelChange,
defaultThinkingLevel,
}: {
thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void;
defaultThinkingLevel?: string;
}) => (
<div data-testid="agent-model-dropdown">
<select
data-testid="custom-model-dropdown-thinking"
value={thinkingLevel || ""}
onChange={(e) => onThinkingLevelChange?.(e.target.value)}
>
{defaultThinkingLevel ? <option value="">Default ({defaultThinkingLevel})</option> : null}
<option value="off">Off</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Very High</option>
</select>
</div>
),
}));
vi.mock("../SkillMultiselect", () => ({
SkillMultiselect: () => <div data-testid="skill-multiselect" />,
}));
vi.mock("../AgentGenerationModal", () => ({
AgentGenerationModal: () => null,
}));
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
ExperimentalAgentOnboardingModal: () => null,
}));
describe("NewAgentDialog thinking level", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("uses CustomModelDropdown thinking control with concrete-only agent semantics", async () => {
render(<NewAgentDialog isOpen onClose={vi.fn()} onCreated={vi.fn()} />);
fireEvent.click(screen.getByTestId("agent-dialog-tab-custom"));
const thinkingSelect = await screen.findByTestId("custom-model-dropdown-thinking") as HTMLSelectElement;
expect(Array.from(thinkingSelect.options).map((option) => option.value)).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
]);
expect(thinkingSelect.value).toBe("off");
expect(screen.queryByText(/Default/)).toBeNull();
expect(screen.queryByLabelText("Thinking Level")).toBeNull();
fireEvent.change(thinkingSelect, { target: { value: "xhigh" } });
await waitFor(() => expect((screen.getByTestId("custom-model-dropdown-thinking") as HTMLSelectElement).value).toBe("xhigh"));
});
});

View File

@@ -245,6 +245,9 @@ vi.mock("../CustomModelDropdown", () => ({
onChange,
label,
disabled,
thinkingLevel,
onThinkingLevelChange,
defaultThinkingLevel,
}: {
value: string;
onChange: (value: string) => void;
@@ -257,9 +260,28 @@ vi.mock("../CustomModelDropdown", () => ({
onToggleFavorite?: (provider: string) => void;
favoriteModels?: string[];
onToggleModelFavorite?: (modelId: string) => void;
thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void;
defaultThinkingLevel?: string;
}) => (
<div data-testid={`custom-model-dropdown-${label}`}>
<span data-testid={`dropdown-value-${label}`}>{value || "none"}</span>
{onThinkingLevelChange ? (
<select
data-testid="custom-model-dropdown-thinking"
value={thinkingLevel || ""}
onChange={(e) => onThinkingLevelChange(e.target.value)}
disabled={disabled}
>
<option value="">Default ({defaultThinkingLevel ?? "off"})</option>
<option value="off">Off</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Very High</option>
</select>
) : null}
<button
data-testid={`dropdown-select-${label}`}
onClick={() => onChange("anthropic/claude-sonnet-4-5")}
@@ -2587,13 +2609,7 @@ describe("QuickEntryBox", () => {
expect(screen.getByTestId("model-menu-validator")).toBeTruthy();
});
/*
FNXC:Settings-ThinkingLevel 2026-07-09-00:00:
The inline quick-entry model menu must expose a Thinking entry with parity to the full task pickers
(TaskForm, ModelSelectorTab, ModelSelectionModal), including a plain <select> submenu with all six
levels plus a named Default option.
*/
it("shows a Thinking option in the model menu", () => {
it("does not render a separate Thinking option in the model menu", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -2601,19 +2617,19 @@ describe("QuickEntryBox", () => {
fireEvent.change(textarea, { target: { value: "Task with models" } });
openModelMenu();
expect(screen.getByTestId("model-menu-thinking")).toBeTruthy();
expect(screen.queryByTestId("model-menu-thinking")).toBeNull();
});
it("clicking Thinking opens a submenu with a level <select>", () => {
it("clicking Executor opens a submenu with the inline thinking-level selector", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
openModelMenu();
fireEvent.click(screen.getByTestId("model-menu-thinking"));
fireEvent.click(screen.getByTestId("model-menu-executor"));
const select = screen.getByTestId("model-thinking-select") as HTMLSelectElement;
const select = screen.getByTestId("custom-model-dropdown-thinking") as HTMLSelectElement;
expect(select).toBeTruthy();
const options = Array.from(select.options).map((o) => o.value);
expect(options).toEqual(["", "off", "minimal", "low", "medium", "high", "xhigh"]);
@@ -3009,8 +3025,8 @@ describe("QuickEntryBox", () => {
fireEvent.change(textarea, { target: { value: "Task with thinking level override" } });
openModelMenu();
fireEvent.click(screen.getByTestId("model-menu-thinking"));
fireEvent.change(screen.getByTestId("model-thinking-select"), { target: { value: "xhigh" } });
fireEvent.click(screen.getByTestId("model-menu-executor"));
fireEvent.change(screen.getByTestId("custom-model-dropdown-thinking"), { target: { value: "xhigh" } });
fireEvent.click(screen.getByTestId("model-submenu-back"));
fireEvent.keyDown(textarea, { key: "Escape" });

View File

@@ -154,9 +154,10 @@ describe("TaskForm", () => {
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect(screen.getByRole("combobox", { name: /Thinking/i })).toBeTruthy();
});
fireEvent.click(await screen.findByRole("button", { name: "Executor Model" }));
const thinkingSelect = await screen.findByTestId("custom-model-dropdown-thinking");
expect(thinkingSelect).toBeTruthy();
expect(screen.getByRole("option", { name: /Very High/i })).toHaveAttribute("value", "xhigh");
});