From f26cfe0cb02cffd99d4df3f80a183cc5ccdbc478 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 17:50:46 -0700 Subject: [PATCH] FN-6145: fix mobile quick-entry touch targets Preserve quick-entry mobile touch behavior for Fast and Priority controls. - treat action touch targets as generic Elements so nested SVG taps still resolve to their parent buttons - add mobile touch-action styling for quick-entry action buttons to improve tap handling - extend QuickEntryBox mobile tests to cover Fast toggle, Priority picker, and SVG touch targets Files changed: packages/dashboard/app/components/QuickEntryBox.css | 5 + packages/dashboard/app/components/QuickEntryBox.tsx | 2 +- packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx | 117 +++++++++++++++++---- 3 files changed, 102 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-6145 Fusion-Task-Lineage: 2af1c142-25ba-41a8-ba4f-c58e38cc870a --- .../app/components/QuickEntryBox.css | 5 + .../app/components/QuickEntryBox.tsx | 2 +- .../__tests__/QuickEntryBox.test.tsx | 117 ++++++++++++++---- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/packages/dashboard/app/components/QuickEntryBox.css b/packages/dashboard/app/components/QuickEntryBox.css index e1c9c8e9b5..e58d52e792 100644 --- a/packages/dashboard/app/components/QuickEntryBox.css +++ b/packages/dashboard/app/components/QuickEntryBox.css @@ -430,8 +430,13 @@ /* === Quick Entry Mobile Touch + Overflow Fixes (FN-1140) === */ @media (max-width: 768px) { + .quick-entry-actions { + touch-action: manipulation; + } + .quick-entry-actions .btn { min-height: 36px; + touch-action: manipulation; } .quick-entry-box .dep-dropdown { diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 713cb37e5e..31cf93455a 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -1484,7 +1484,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, data-testid="quick-entry-actions" onTouchStart={(e: React.TouchEvent) => { const target = e.target; - if (!(target instanceof HTMLElement)) return; + if (!(target instanceof Element)) return; const button = target.closest("button"); if (button && !button.disabled) { e.preventDefault(); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index 16593fbb77..617378d318 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -120,25 +120,28 @@ vi.mock("../../hooks/useNodes", () => ({ })); // Mock lucide-react -vi.mock("lucide-react", () => ({ - Link: () => null, - Paperclip: () => null, - Brain: () => null, - Lightbulb: () => null, - ListTree: () => null, - Sparkles: () => null, - Save: () => null, - X: () => null, - ChevronDown: () => null, - ChevronUp: () => null, - ChevronRight: () => null, - Bot: () => null, - Server: () => null, - Flag: () => null, - Github: () => null, - Maximize2: () => null, - Minimize2: () => null, -})); +vi.mock("lucide-react", () => { + const MockIcon = (props: any) => ; + return { + Link: MockIcon, + Paperclip: MockIcon, + Brain: MockIcon, + Lightbulb: MockIcon, + ListTree: MockIcon, + Sparkles: MockIcon, + Save: MockIcon, + X: MockIcon, + ChevronDown: MockIcon, + ChevronUp: MockIcon, + ChevronRight: MockIcon, + Bot: MockIcon, + Server: MockIcon, + Flag: MockIcon, + Github: MockIcon, + Maximize2: MockIcon, + Minimize2: MockIcon, + }; +}); // Mock ModelSelectionModal (kept for backward compatibility - no longer directly rendered) vi.mock("../ModelSelectionModal", () => ({ @@ -479,14 +482,14 @@ describe("QuickEntryBox", () => { return textarea; } - function fireCancelableTouchStart(target: HTMLElement) { + function fireCancelableTouchStart(target: Element) { const event = new Event("touchstart", { bubbles: true, cancelable: true }); const preventDefaultSpy = vi.spyOn(event, "preventDefault"); fireEvent(target, event); return { preventDefaultSpy }; } - async function touchActionButton(button: HTMLElement) { + async function touchActionButton(button: Element) { const { preventDefaultSpy } = fireCancelableTouchStart(button); expect(preventDefaultSpy).toHaveBeenCalled(); await act(async () => { @@ -496,6 +499,78 @@ describe("QuickEntryBox", () => { }); } + async function touchPriorityOption(option: Element) { + await act(async () => { + fireEvent.touchStart(option); + fireEvent.touchEnd(option); + fireEvent.click(option); + vi.runOnlyPendingTimers(); + vi.runOnlyPendingTimers(); + }); + } + + it("captures an SVG touch target inside the priority button and opens the picker", async () => { + await renderMobileQuickEntryWithEnabledActions(); + const priorityButton = screen.getByTestId("quick-entry-priority-button"); + const svg = priorityButton.querySelector("svg"); + expect(svg).not.toBeNull(); + + const { preventDefaultSpy } = fireCancelableTouchStart(svg!); + expect(preventDefaultSpy).toHaveBeenCalled(); + await act(async () => { + fireEvent(svg!, new Event("touchend", { bubbles: true, cancelable: true })); + vi.runOnlyPendingTimers(); + vi.runOnlyPendingTimers(); + }); + + expect(await screen.findByTestId("quick-entry-priority-option-normal")).toBeTruthy(); + }); + + it("toggles Fast pressed state via mobile touch", async () => { + await renderMobileQuickEntryWithEnabledActions(); + const fastToggle = screen.getByTestId("quick-entry-fast-toggle"); + + expect(fastToggle.getAttribute("aria-pressed")).toBe("false"); + await touchActionButton(fastToggle); + expect(fastToggle.getAttribute("aria-pressed")).toBe("true"); + }); + + it("opens the priority picker via mobile touch", async () => { + await renderMobileQuickEntryWithEnabledActions(); + await touchActionButton(screen.getByTestId("quick-entry-priority-button")); + + expect(await screen.findByTestId("quick-entry-priority-option-normal")).toBeTruthy(); + }); + + it("selects a priority option after mobile touch opens the picker", async () => { + await renderMobileQuickEntryWithEnabledActions(); + const priorityButton = screen.getByTestId("quick-entry-priority-button"); + + await touchActionButton(priorityButton); + const highOption = await screen.findByTestId("quick-entry-priority-option-high"); + await touchPriorityOption(highOption); + + expect(priorityButton.textContent).toContain("High"); + await waitFor(() => { + expect(screen.queryByTestId("quick-entry-priority-option-normal")).toBeNull(); + }); + }); + + it.each([ + ["Priority", "quick-entry-priority-button"], + ["Models", "quick-entry-models"], + ["Node", "quick-entry-node-button"], + ["Agent", "quick-entry-agent-button"], + ] as const)("captures SVG touches on the %s action button", async (_label, testId) => { + await renderMobileQuickEntryWithEnabledActions(); + const button = screen.getByTestId(testId); + const svg = button.querySelector("svg"); + expect(svg).not.toBeNull(); + + const { preventDefaultSpy } = fireCancelableTouchStart(svg!); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + it.each(QUICK_ENTRY_ACTION_BUTTONS)("keeps textarea focused during mobile touch on %s", async (_label, testId) => { await renderMobileQuickEntryWithEnabledActions(); const textarea = focusTextareaWithValue(`Mobile touch preserves focus for ${testId}`);