diff --git a/.changeset/fix-quick-add-model-filter.md b/.changeset/fix-quick-add-model-filter.md new file mode 100644 index 0000000000..b47f22fcc5 --- /dev/null +++ b/.changeset/fix-quick-add-model-filter.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the Quick Add model dropdown filter box so typing narrows the model list. +category: fix +dev: The quick-entry model menu's blanket onMouseDown preventDefault crossed the React portal boundary and suppressed focus on CustomModelDropdown's search input. diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 414b3bb544..12b05ee9fe 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -2402,7 +2402,19 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
e.preventDefault()} + onMouseDown={(e) => { + /* + FNXC:QuickAddModels 2026-08-12-21:51: + React synthetic mouse events cross createPortal boundaries, so this composer-focus guard + must exempt form controls and the portaled model dropdown or preventDefault suppresses + focus for its filter input. Plain menu chrome still preserves the quick-entry focus. + */ + const target = e.target as Element; + if (target.closest("input, textarea, select, [contenteditable], .model-combobox-dropdown--portal")) { + return; + } + e.preventDefault(); + }} data-testid="model-nested-menu" style={{ position: "fixed", diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox-model-filter.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox-model-filter.test.tsx new file mode 100644 index 0000000000..8e759ca64a --- /dev/null +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox-model-filter.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QuickEntryBox } from "../QuickEntryBox"; +import type { ModelInfo } from "../../api"; + +vi.mock("../../api", () => ({ + fetchModels: vi.fn(), + fetchSettings: vi.fn().mockResolvedValue({ + modelPresets: [], + autoSelectModelPreset: false, + defaultPresetBySize: {}, + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 30_000, + groupOverlappingFiles: true, + autoMerge: true, + }), + fetchAgents: vi.fn().mockResolvedValue([]), + checkDuplicateTasks: vi.fn().mockResolvedValue([]), + uploadAttachment: vi.fn().mockResolvedValue({}), + fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]), + updateGlobalSettings: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../../hooks/useNodes", () => ({ + useNodes: () => ({ + nodes: [], + loading: false, + error: null, + refresh: vi.fn(), + register: vi.fn(), + update: vi.fn(), + unregister: vi.fn(), + healthCheck: vi.fn(), + }), +})); + +vi.mock("../../hooks/useComposerDictation", () => ({ + useComposerDictation: () => ({ micProps: { enabled: false, supported: false, state: "idle", start: vi.fn(), stop: vi.fn() } }), +})); + +const models: ModelInfo[] = [ + { provider: "anthropic", id: "claude-sonnet", name: "Claude Sonnet", reasoning: true, contextWindow: 200_000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128_000 }, +]; + +const originalInnerWidth = Object.getOwnPropertyDescriptor(window, "innerWidth"); + +function setViewport(width: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); +} + +function renderQuickEntry() { + return render( + , + ); +} + +/** + * FNXC:QuickAddModels 2026-08-12-21:51: + * This regression uses the real portaled CustomModelDropdown because a stub cannot prove that + * a React portal ancestor's mousedown handling preserves focus for the nested filter input. + */ +describe("QuickEntryBox model filter", () => { + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ""; + }); + + afterEach(() => { + if (originalInnerWidth) { + Object.defineProperty(window, "innerWidth", originalInnerWidth); + } + localStorage.clear(); + }); + + it.each([ + { lane: "executor", width: 1280, query: "clau", expected: "Claude Sonnet", absent: "GPT-4o" }, + { lane: "validator", width: 375, query: "no-match", expected: null, absent: "Claude Sonnet" }, + ])("focuses and filters the $lane model dropdown at $widthpx", async ({ lane, width, query, expected, absent }) => { + setViewport(width); + const user = userEvent.setup(); + renderQuickEntry(); + + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + await user.type(textarea, "Keep this description"); + await user.click(screen.getByTestId("quick-entry-models")); + await user.click(screen.getByTestId(`model-menu-${lane}`)); + await user.click(screen.getByRole("button", { name: `${lane} model` })); + + const filterInput = await screen.findByPlaceholderText("Filter models…"); + await user.click(filterInput); + expect(document.activeElement).toBe(filterInput); + await user.keyboard(query); + + expect(filterInput).toHaveValue(query); + expect(screen.getByTestId("model-nested-menu")).toBeInTheDocument(); + expect(textarea).toHaveValue("Keep this description"); + + if (expected) { + expect(screen.getByText(expected)).toBeInTheDocument(); + } else { + expect(screen.getByText(/No models match/)).toBeInTheDocument(); + } + expect(screen.queryByText(absent)).not.toBeInTheDocument(); + }); +});