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
|
<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",
|
||||||
|
|||||||
@@ -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