From 2c4052c76b76634482f77bbfbf36551e58de2395 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 9 Jul 2026 13:51:45 -0700 Subject: [PATCH] FN-7746: Add thinking level selector to model pickers Adds a thinking-level (reasoning-effort) selector to the quick-create model pickers, matching the levels already available in the full task pickers. - ModelSelectionModal: adds optional thinking-level onThinkingLevelChange(e.target.value)} + > + + + + + + + + + + + )} diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 87ca77fb7d..114ac5bff1 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import type { ToastType } from "../hooks/useToast"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core"; -import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core"; +import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep, ThinkingLevel } from "@fusion/core"; import type { ModelInfo, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api"; import { checkDuplicateTasks, fetchModels, fetchSettings, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api"; import { DuplicateWarningModal } from "./DuplicateWarningModal"; @@ -170,13 +170,15 @@ 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" | null>(null); + const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | "thinking" | null>(null); const [executorProvider, setExecutorProvider] = useState(undefined); const [executorModelId, setExecutorModelId] = useState(undefined); const [validatorProvider, setValidatorProvider] = useState(undefined); const [validatorModelId, setValidatorModelId] = useState(undefined); const [planningProvider, setPlanningProvider] = useState(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); + /* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: inline quick-entry bar carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */ + const [thinkingLevel, setThinkingLevel] = useState(""); const modelTriggerRef = useRef(null); const modelMenuPortalRef = useRef(null); const agentPickerRef = useRef(null); @@ -413,6 +415,7 @@ 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") @@ -607,6 +610,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, setValidatorModelId(undefined); setPlanningProvider(undefined); setPlanningModelId(undefined); + setThinkingLevel(""); setSelectedPresetId(undefined); setEnabledOptionalStepIds(optionalSteps.filter((step) => step.defaultOn).map((step) => step.templateId)); setIsFastMode(false); @@ -731,6 +735,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, validatorModelId: hasValidatorOverride ? validatorModelId : undefined, planningModelProvider: hasPlanningOverride ? planningProvider : undefined, planningModelId: hasPlanningOverride ? planningModelId : undefined, + thinkingLevel: thinkingLevel !== "" ? (thinkingLevel as ThinkingLevel) : undefined, /* FNXC:QuickAddWorkflowSteps 2026-06-29-01:31: Quick Add optional-step toggles are explicit task intent. When the workflow exposes optional steps and the user unchecks every one, submit an empty array instead of omitting the field so default-on Plan Review / Code Review do not reappear on the created task. @@ -780,6 +785,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, hasPlanningOverride, planningProvider, planningModelId, + thinkingLevel, enabledOptionalStepIds, isFastMode, settings, @@ -1497,6 +1503,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, setValidatorModelId(next.modelId); }, []); + const handleThinkingLevelChange = useCallback((value: string) => { + setThinkingLevel(value); + }, []); + const handleToggleFavorite = useCallback(async (provider: string) => { // Delegate to parent callback when available if (parentToggleFavorite) { @@ -2341,6 +2351,59 @@ 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 diff --git a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx index c4c5d35c34..0a3f3f8343 100644 --- a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx @@ -40,6 +40,8 @@ vi.mock("../ModelSelectionModal", () => ({ onExecutorChange, onValidatorChange, onPlanningChange, + thinkingLevel, + onThinkingLevelChange, modelsLoading, modelsError, onRetry, @@ -58,6 +60,8 @@ vi.mock("../ModelSelectionModal", () => ({ onExecutorChange: (value: string) => void; onValidatorChange: (value: string) => void; onPlanningChange: (value: string) => void; + thinkingLevel?: string; + onThinkingLevelChange?: (value: string) => void; modelsLoading: boolean; modelsError: string | null; onRetry: () => void; @@ -73,6 +77,7 @@ vi.mock("../ModelSelectionModal", () => ({
{executorValue}
{validatorValue}
{planningValue}
+
{thinkingLevel ?? ""}
{modelsLoading ? "loading" : "not-loading"}
{modelsError || "no-error"}
{JSON.stringify(favoriteModels ?? [])}
@@ -87,6 +92,8 @@ vi.mock("../ModelSelectionModal", () => ({ + + ); @@ -612,6 +619,69 @@ describe("InlineCreateCard model selector", () => { }); }); + it("omits thinkingLevel from the submit payload when the thinking selector is left at default", async () => { + const { props } = renderCard(); + expandCard(); + const textarea = screen.getByPlaceholderText("What needs to be done?"); + + fireEvent.change(textarea, { target: { value: "Task using default thinking level" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(props.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Task using default thinking level", + thinkingLevel: undefined, + }), + ); + }); + }); + + it("forwards a non-default thinkingLevel in the submit payload", async () => { + const { props } = renderCard(); + expandCard(); + const textarea = screen.getByPlaceholderText("What needs to be done?"); + + fireEvent.change(textarea, { target: { value: "Task with thinking level override" } }); + openModelModal(); + fireEvent.click(screen.getByTestId("modal-select-thinking")); + fireEvent.click(screen.getByTestId("modal-close")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(props.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Task with thinking level override", + thinkingLevel: "high", + }), + ); + }); + }); + + it("clears thinkingLevel back to undefined when reset to Default", async () => { + const { props } = renderCard(); + expandCard(); + const textarea = screen.getByPlaceholderText("What needs to be done?"); + + fireEvent.change(textarea, { target: { value: "Task clearing thinking level" } }); + openModelModal(); + fireEvent.click(screen.getByTestId("modal-select-thinking")); + expect(screen.getByTestId("modal-props-thinking-level").textContent).toBe("high"); + fireEvent.click(screen.getByTestId("modal-clear-thinking")); + expect(screen.getByTestId("modal-props-thinking-level").textContent).toBe(""); + fireEvent.click(screen.getByTestId("modal-close")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(props.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Task clearing thinking level", + thinkingLevel: undefined, + }), + ); + }); + }); + it("does NOT call onCancel when focus leaves while the model modal is open", () => { const { props } = renderCard(); expandCard(); diff --git a/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx index 6089b72e08..bd57c0e4d9 100644 --- a/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx @@ -277,6 +277,75 @@ 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(); + }); + + 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; + expect(select).toBeTruthy(); + + const options = Array.from(select.options).map((o) => o.value); + expect(options).toEqual(["", "off", "minimal", "low", "medium", "high", "xhigh"]); + }); + + 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(); + }); + + it("still omits the thinking-level selector while models are loading", () => { + renderModelSelectionModal({ modelsLoading: true, onThinkingLevelChange: vi.fn() }); + expect(screen.queryByTestId("model-selection-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(); + }); + + it("calls onThinkingLevelChange with the selected level", () => { + const onThinkingLevelChange = vi.fn(); + renderModelSelectionModal({ onThinkingLevelChange }); + + const select = screen.getByTestId("model-selection-thinking"); + fireEvent.change(select, { target: { value: "high" } }); + + expect(onThinkingLevelChange).toHaveBeenCalledWith("high"); + }); + + it("calls onThinkingLevelChange with '' when selecting the Default option", () => { + const onThinkingLevelChange = vi.fn(); + renderModelSelectionModal({ onThinkingLevelChange, thinkingLevel: "high" }); + + const select = screen.getByTestId("model-selection-thinking"); + fireEvent.change(select, { target: { value: "" } }); + + expect(onThinkingLevelChange).toHaveBeenCalledWith(""); + }); + + 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"); + 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"); + expect(badge.textContent).toBe("xhigh"); + expect(badge.classList.contains("model-badge-custom")).toBe(true); + }); + }); + describe("Preset selection", () => { it("does not show preset selector when presets prop is omitted", () => { renderModelSelectionModal(); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 69f47d30f8..a4c429fdf7 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -2587,6 +2587,39 @@ 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 ", () => { + 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")); + + const select = screen.getByTestId("model-thinking-select") as HTMLSelectElement; + expect(select).toBeTruthy(); + const options = Array.from(select.options).map((o) => o.value); + expect(options).toEqual(["", "off", "minimal", "low", "medium", "high", "xhigh"]); + expect(screen.getByTestId("model-submenu-back")).toBeTruthy(); + }); + it("clicking Executor opens submenu with CustomModelDropdown", () => { renderQuickEntryBox({}); expandQuickEntry(); @@ -2969,6 +3002,48 @@ describe("QuickEntryBox", () => { expect(addToast).not.toHaveBeenCalled(); }); + it("forwards a non-default thinkingLevel selection in the submit payload", async () => { + const { props } = renderQuickEntryBox({}); + expandQuickEntry(); + const textarea = screen.getByTestId("quick-entry-input"); + + 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-submenu-back")); + + fireEvent.keyDown(textarea, { key: "Escape" }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => { + expect(props.onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Task with thinking level override", + thinkingLevel: "xhigh", + }), + ); + }); + }); + + it("omits thinkingLevel from submit payload when left at default", async () => { + const { props } = renderQuickEntryBox({}); + expandQuickEntry(); + const textarea = screen.getByTestId("quick-entry-input"); + + fireEvent.change(textarea, { target: { value: "Task without thinking override" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => { + expect(props.onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Task without thinking override", + thinkingLevel: undefined, + }), + ); + }); + }); + it("includes all three selected model pairs in submit payload", async () => { const { props } = renderQuickEntryBox({}); expandQuickEntry(); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 60e46efc9b..a5779f41a9 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -4004,6 +4004,8 @@ "preset": "Preset", "reviewerModel": "Reviewer Model", "reviewerPlaceholder": "Select reviewer model…", + "thinkingDefault": "Default ({{level}})", + "thinkingModel": "Thinking", "title": "Select Models", "useDefault": "Use default", "usingDefault": "Using default" diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 5ad8070134..128ad153af 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -3994,6 +3994,8 @@ "preset": "Predefinido", "reviewerModel": "Modelo revisor", "reviewerPlaceholder": "Seleccionar modelo revisor…", + "thinkingDefault": "", + "thinkingModel": "", "title": "Seleccionar modelos", "useDefault": "Usar predeterminado", "usingDefault": "Usando predeterminado" diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 99cacce3c0..2e5a1a7317 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -3994,6 +3994,8 @@ "preset": "Préréglage", "reviewerModel": "Modèle réviseur", "reviewerPlaceholder": "Sélectionner le modèle réviseur…", + "thinkingDefault": "", + "thinkingModel": "", "title": "Sélectionner les modèles", "useDefault": "Utiliser par défaut", "usingDefault": "Par défaut" diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index f8d028a369..b544d7e900 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -3994,6 +3994,8 @@ "preset": "프리셋", "reviewerModel": "검토자 모델", "reviewerPlaceholder": "검토자 모델 선택…", + "thinkingDefault": "", + "thinkingModel": "", "title": "모델 선택", "useDefault": "기본값 사용", "usingDefault": "기본값 사용 중" diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 748e5146a9..e9379da829 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -3994,6 +3994,8 @@ "preset": "预设", "reviewerModel": "审查模型", "reviewerPlaceholder": "选择审查模型…", + "thinkingDefault": "", + "thinkingModel": "", "title": "选择模型", "useDefault": "使用默认", "usingDefault": "使用默认" diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 496f2f36f7..e29a95cdc8 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -3994,6 +3994,8 @@ "preset": "預設", "reviewerModel": "審查模型", "reviewerPlaceholder": "選擇審查模型…", + "thinkingDefault": "", + "thinkingModel": "", "title": "選擇模型", "useDefault": "使用預設", "usingDefault": "使用預設"