FN-9010: fix Quick Add model filtering

Keep the Quick Add model filter focused and functional within its portaled menu.

- Exempt form controls and the nested model dropdown from the composer focus guard.
- Add desktop and mobile regression coverage for filtering model choices.
- Add a patch changeset for the Quick Add filter fix.

Files changed:
 .changeset/fix-quick-add-model-filter.md           |   7 ++
 .../dashboard/app/components/QuickEntryBox.tsx     |  14 ++-
 .../__tests__/QuickEntryBox-model-filter.test.tsx  | 113 +++++++++++++++++++++
 3 files changed, 133 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-9010

Fusion-Task-Lineage: da86e2d8-8722-4598-b25e-786b5850971c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 15:02:18 -07:00
parent c4467b0a5b
commit 6e04b31579
3 changed files with 133 additions and 1 deletions

View File

@@ -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.

View File

@@ -2402,7 +2402,19 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
<div <div
ref={modelMenuPortalRef} ref={modelMenuPortalRef}
className="model-nested-menu model-nested-menu--portal" className="model-nested-menu model-nested-menu--portal"
onMouseDown={(e) => 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" data-testid="model-nested-menu"
style={{ style={{
position: "fixed", position: "fixed",

View File

@@ -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(
<QuickEntryBox
addToast={vi.fn()}
availableModels={models}
onCreate={vi.fn().mockResolvedValue(undefined)}
projectId="quick-entry-model-filter"
/>,
);
}
/**
* 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();
});
});