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:
7
.changeset/fix-quick-add-model-filter.md
Normal file
7
.changeset/fix-quick-add-model-filter.md
Normal 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.
|
||||
@@ -2402,7 +2402,19 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
<div
|
||||
ref={modelMenuPortalRef}
|
||||
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"
|
||||
style={{
|
||||
position: "fixed",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user