diff --git a/.changeset/fn-7768-inline-thinking-dropdown.md b/.changeset/fn-7768-inline-thinking-dropdown.md new file mode 100644 index 0000000000..4b773fa0cc --- /dev/null +++ b/.changeset/fn-7768-inline-thinking-dropdown.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index dfe6769c2c..eb11057a8d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css index 937222fea5..34653c6e5b 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.css +++ b/packages/dashboard/app/components/CustomModelDropdown.css @@ -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. diff --git a/packages/dashboard/app/components/CustomModelDropdown.tsx b/packages/dashboard/app/components/CustomModelDropdown.tsx index 3b9c317174..5f08e60427 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.tsx +++ b/packages/dashboard/app/components/CustomModelDropdown.tsx @@ -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(null); const [portalRoot, setPortalRoot] = useState(null); + const generatedThinkingId = useId(); const containerRef = useRef(null); const triggerRef = useRef(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" })} + {shouldShowThinking && ( +
e.stopPropagation()} onClick={(e) => e.stopPropagation()}> + + +
+ )} +
{specialOptions.map((option, index) => (
)} {selectedDisplayText || placeholder} + {shouldShowThinking && ( + + {thinkingBadgeLabel} + + )} ▼
diff --git a/packages/dashboard/app/components/ModelSelectionModal.tsx b/packages/dashboard/app/components/ModelSelectionModal.tsx index 85edb78fc5..d4d177ccd0 100644 --- a/packages/dashboard/app/components/ModelSelectionModal.tsx +++ b/packages/dashboard/app/components/ModelSelectionModal.tsx @@ -296,6 +296,9 @@ export function ModelSelectionModal({ onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} + thinkingLevel={thinkingLevel} + onThinkingLevelChange={onThinkingLevelChange} + defaultThinkingLevel={defaultThinkingLevel ?? "off"} />
@@ -326,35 +329,6 @@ export function ModelSelectionModal({ - {onThinkingLevelChange && ( -
-
- - - {thinkingLevel || t("modelSelection.usingDefault", "Using default")} - - -
-
- )} diff --git a/packages/dashboard/app/components/ModelSelectorTab.tsx b/packages/dashboard/app/components/ModelSelectorTab.tsx index 2d28113c1d..538bdc02a3 100644 --- a/packages/dashboard/app/components/ModelSelectorTab.tsx +++ b/packages/dashboard/app/components/ModelSelectorTab.tsx @@ -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"} /> {t("models.descriptions.executor", "The AI model used to implement this task.")} @@ -479,36 +482,6 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj {t("models.descriptions.planning", "The AI model used for task specification (triage phase).")} -
- -
- {savedThinking === null ? ( - - {t("models.states.usingDefault", "Using default")} ({settings?.defaultThinkingLevel ?? "off"}) - - ) : ( - - {savedThinking} - - )} -
- - {t("models.descriptions.thinkingLevel", "Controls the reasoning effort for the AI agent. Higher levels use more tokens.")} -
{executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === null diff --git a/packages/dashboard/app/components/NewAgentDialog.tsx b/packages/dashboard/app/components/NewAgentDialog.tsx index b98ddb6366..7f970e23e7 100644 --- a/packages/dashboard/app/components/NewAgentDialog.tsx +++ b/packages/dashboard/app/components/NewAgentDialog.tsx @@ -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 }))} /> )}
@@ -674,22 +676,6 @@ export function NewAgentDialog({ {step === 1 && (
{renderRuntimeSourceSection("agent-runtime-source-step-1")} -
- - -
(null); + const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null); const [executorProvider, setExecutorProvider] = useState(undefined); const [executorModelId, setExecutorModelId] = useState(undefined); const [validatorProvider, setValidatorProvider] = useState(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, - {/* - 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. - */} - -
- ) : activeModelSubmenu === "thinking" ? ( - // Submenu with a plain handleThinkingLevelChange(e.target.value)} - > - - - - - - - -
) : ( // 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 && (
diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 31c613a701..339078283b 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -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"} />
@@ -1569,26 +1572,6 @@ export function TaskForm({ />
)} - {onThinkingLevelChange && ( -
- {/* 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`. */} - - -
- )} {onPlannerOversightLevelChange && (
{/* 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. */} diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx index 9f9dcf5a16..5d705ce2b5 100644 --- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); diff --git a/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx index bd57c0e4d9..9711a79b2a 100644 --- a/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx @@ -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; }) => (
{label} {value || "empty"} + {onThinkingLevelChange ? ( + <> + + {thinkingLevel || "Default"} + + + + ) : null} onThinkingLevelChange?.(e.target.value)} + > + {defaultThinkingLevel ? : null} + + + + + + + +
+ ), +})); + +vi.mock("../SkillMultiselect", () => ({ + SkillMultiselect: () =>
, +})); + +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(); + + 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")); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index a4c429fdf7..c05dfeddcc 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -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; }) => (
{value || "none"} + {onThinkingLevelChange ? ( + + ) : null}