FN-6211: prevent QuickEntry option taps from refocusing input

Prevents mobile option taps from re-opening the QuickEntry textarea keyboard.

- suppresses textarea refocus briefly while QuickEntry action buttons handle touch interactions
- only prevents default touch behavior when the textarea is already focused
- adds mobile regression coverage for blurred action toggles, menus, attachments, refine, planning, subtasks, and save flows

Files changed:
 .../dashboard/app/components/QuickEntryBox.tsx     |  17 ++-
 .../components/__tests__/QuickEntryBox.test.tsx    | 148 +++++++++++++++++++++
 2 files changed, 164 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-6211

Fusion-Task-Lineage: 9e20c4e3-8b19-4374-b492-745a92c7bb10
This commit is contained in:
gsxdsm
2026-06-11 14:03:16 -07:00
parent 535c40d247
commit 7923148490
2 changed files with 164 additions and 1 deletions

View File

@@ -105,6 +105,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
const suppressRefocusRef = useRef(false);
const justResetRef = useRef(false);
const previousProjectIdRef = useRef(projectId);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
@@ -736,6 +737,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}, []);
const handleFocus = useCallback(() => {
if (suppressRefocusRef.current) {
textareaRef.current?.blur();
return;
}
// Auto-expand on focus when autoExpand prop is true (default)
if (autoExpand) {
setIsExpanded(true);
@@ -1487,15 +1493,24 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
if (!(target instanceof Element)) return;
const button = target.closest("button");
if (button && !button.disabled) {
e.preventDefault();
if (document.activeElement === textareaRef.current) {
e.preventDefault();
}
touchButtonRef.current = button;
suppressRefocusRef.current = true;
}
}}
onTouchEnd={() => {
touchButtonRef.current = null;
window.setTimeout(() => {
suppressRefocusRef.current = false;
}, 150);
}}
onTouchCancel={() => {
touchButtonRef.current = null;
window.setTimeout(() => {
suppressRefocusRef.current = false;
}, 150);
}}
>
<button

View File

@@ -480,6 +480,10 @@ describe("QuickEntryBox", () => {
await waitFor(() => {
expect(screen.getByTestId("quick-entry-github-toggle")).not.toBeDisabled();
});
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
textarea.focus();
fireEvent.focus(textarea);
expect(document.activeElement).toBe(textarea);
return result;
}
@@ -708,6 +712,150 @@ describe("QuickEntryBox", () => {
});
});
describe("button focus — no refocus when textarea is blurred (FN-6211)", () => {
async function renderBlurredMobileQuickEntry() {
mockMobileViewport();
vi.mocked(fetchSettings).mockResolvedValueOnce({
githubTrackingEnabledByDefault: true,
} as any);
const onPlanningMode = vi.fn();
const onSubtaskBreakdown = vi.fn();
const result = renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown });
expandQuickEntry();
await waitFor(() => {
expect(screen.getByTestId("quick-entry-github-toggle")).not.toBeDisabled();
});
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
textarea.focus();
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Adjust options without keyboard" } });
textarea.blur();
fireEvent.blur(textarea);
expect(document.activeElement).not.toBe(textarea);
return { ...result, textarea, onPlanningMode, onSubtaskBreakdown };
}
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 touchActionButtonWithoutRefocus(button: Element, textarea: HTMLTextAreaElement) {
const { preventDefaultSpy } = fireCancelableTouchStart(button);
expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(document.activeElement).not.toBe(textarea);
await act(async () => {
fireEvent(button, new Event("touchend", { bubbles: true, cancelable: true }));
fireEvent.click(button);
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
});
expect(document.activeElement).not.toBe(textarea);
}
async function assertBlurredButtonActionStillWorks(
testId: string,
helpers: Awaited<ReturnType<typeof renderBlurredMobileQuickEntry>>,
attachClickSpy?: ReturnType<typeof vi.spyOn>,
) {
switch (testId) {
case "quick-entry-fast-toggle":
expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", "true");
break;
case "quick-entry-github-toggle":
expect(screen.getByTestId(testId)).toHaveAttribute("aria-pressed", "false");
break;
case "quick-entry-priority-button":
expect(await screen.findByTestId("quick-entry-priority-option-normal")).toBeTruthy();
break;
case "quick-entry-deps":
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
break;
case "quick-entry-models":
expect(await screen.findByTestId("model-nested-menu")).toBeTruthy();
break;
case "quick-entry-node-button":
expect(document.querySelector(".node-picker-dropdown")).toBeTruthy();
break;
case "quick-entry-agent-button":
expect(document.querySelector(".agent-picker-dropdown")).toBeTruthy();
break;
case "quick-entry-attach":
expect(attachClickSpy).toHaveBeenCalled();
break;
case "refine-button":
expect(await screen.findByTestId("refine-clarify")).toBeTruthy();
break;
case "plan-button":
expect(helpers.onPlanningMode).toHaveBeenCalledWith("Adjust options without keyboard");
break;
case "subtask-button":
expect(helpers.onSubtaskBreakdown).toHaveBeenCalledWith("Adjust options without keyboard");
break;
case "quick-entry-save":
await waitFor(() => {
expect(helpers.props.onCreate).toHaveBeenCalled();
});
break;
default:
throw new Error(`Unhandled QuickEntry action test id: ${testId}`);
}
}
it.each(QUICK_ENTRY_ACTION_BUTTONS)(
"does not refocus textarea when tapping %s with textarea blurred",
async (_label, testId) => {
const helpers = await renderBlurredMobileQuickEntry();
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
const attachClickSpy = vi.spyOn(fileInput, "click");
const button = screen.getByTestId(testId);
await touchActionButtonWithoutRefocus(button, helpers.textarea);
await assertBlurredButtonActionStillWorks(testId, helpers, attachClickSpy);
expect(document.activeElement).not.toBe(helpers.textarea);
},
);
it("does not refocus textarea when selecting a priority option after blurred touch open", async () => {
const { textarea } = await renderBlurredMobileQuickEntry();
const priorityButton = screen.getByTestId("quick-entry-priority-button");
await touchActionButtonWithoutRefocus(priorityButton, textarea);
const highOption = await screen.findByTestId("quick-entry-priority-option-high");
await act(async () => {
fireEvent.touchStart(highOption);
fireEvent.touchEnd(highOption);
fireEvent.click(highOption);
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
});
expect(priorityButton.textContent).toContain("High");
expect(document.activeElement).not.toBe(textarea);
});
it("does not refocus textarea when selecting a dependency after blurred touch open", async () => {
const { textarea } = await renderBlurredMobileQuickEntry();
const depsButton = screen.getByTestId("quick-entry-deps");
await touchActionButtonWithoutRefocus(depsButton, textarea);
const depItem = document.querySelector(".dep-dropdown-item");
expect(depItem).toBeTruthy();
await act(async () => {
fireEvent.touchStart(depItem!);
fireEvent.touchEnd(depItem!);
fireEvent.click(depItem!);
vi.runOnlyPendingTimers();
vi.runOnlyPendingTimers();
});
expect(depsButton.textContent).toContain("1 dep");
expect(document.activeElement).not.toBe(textarea);
});
});
it("textarea spans full container width (FN-1608)", () => {
mockDesktopViewport();
renderQuickEntryBox({});