diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index ed92cd322c..f5a8b1d2e8 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -97,6 +97,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, return ""; }); const [isSubmitting, setIsSubmitting] = useState(false); + const [postSubmitFocusRequest, setPostSubmitFocusRequest] = useState(0); // isExpanded controls textarea height styling (auto-resize) const [isExpanded, setIsExpanded] = useState(true); // isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.) @@ -106,7 +107,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const fileInputRef = useRef(null); const touchButtonRef = useRef(null); const justResetRef = useRef(false); - const justSubmittedRef = useRef(false); + const postSubmitFocusTimeoutRef = useRef | null>(null); + const handledPostSubmitFocusRequestRef = useRef(0); const previousProjectIdRef = useRef(projectId); const [pendingImages, setPendingImages] = useState([]); const pendingImagesRef = useRef([]); @@ -321,21 +323,46 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, } }, [description, isExpanded, autoResize]); - // Restore focus after an in-component submission completes (when textarea is re-enabled). + const requestFocusAfterSuccessfulSubmit = useCallback(() => { + setPostSubmitFocusRequest((request) => request + 1); + }, []); + + /* + FNXC:QuickEntryFocus 2026-06-19-16:50: + Desktop users should keep typing after Enter, Save, or duplicate-confirmed task creation, while mobile users must not receive an automatic focus that opens the soft keyboard. + Drive the post-submit focus from a resolved-submit state request instead of a ref-gated effect so React state ordering cannot skip the restoration when the form clears under broad jsdom load. + */ useEffect(() => { - if (!justSubmittedRef.current || isSubmitting || description !== "" || !textareaRef.current) { + if ( + postSubmitFocusRequest === 0 || + handledPostSubmitFocusRequestRef.current === postSubmitFocusRequest || + isSubmitting || + description !== "" || + !textareaRef.current + ) { return; } - justSubmittedRef.current = false; - // Use setTimeout to ensure focus happens after React re-enables the textarea. - const focusTimeout = setTimeout(() => { + handledPostSubmitFocusRequestRef.current = postSubmitFocusRequest; + + if (postSubmitFocusTimeoutRef.current) { + clearTimeout(postSubmitFocusTimeoutRef.current); + } + + postSubmitFocusTimeoutRef.current = setTimeout(() => { + postSubmitFocusTimeoutRef.current = null; if (typeof window !== "undefined" && window.innerWidth > MOBILE_BREAKPOINT_PX) { textareaRef.current?.focus(); } }, 0); - return () => clearTimeout(focusTimeout); - }, [isSubmitting, description]); + + return () => { + if (postSubmitFocusTimeoutRef.current) { + clearTimeout(postSubmitFocusTimeoutRef.current); + postSubmitFocusTimeoutRef.current = null; + } + }; + }, [description, isSubmitting, postSubmitFocusRequest]); // Clear dep search when dropdown closes useEffect(() => { @@ -542,7 +569,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, } } resetForm(); - justSubmittedRef.current = true; + requestFocusAfterSuccessfulSubmit(); } catch (err) { setDescription(originalDescription); addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error"); @@ -574,6 +601,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, projectId, addToast, resetForm, + requestFocusAfterSuccessfulSubmit, ]); const handleSubmit = useCallback(async () => { diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index f1853f2c5a..7c54164abc 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -277,6 +277,10 @@ async function flushPendingTimers() { }); } +async function waitForSubmitSuccessToClear(textarea: HTMLTextAreaElement) { + await waitFor(() => expect(textarea.value).toBe("")); +} + function openPriorityMenu() { fireEvent.click(screen.getByTestId("quick-entry-priority-button")); } @@ -456,6 +460,7 @@ describe("QuickEntryBox", () => { fireEvent.keyDown(textarea, { key: "Enter" }); await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await waitForSubmitSuccessToClear(textarea); await flushPendingTimers(); expect(focusSpy).toHaveBeenCalledTimes(1); @@ -473,6 +478,7 @@ describe("QuickEntryBox", () => { clickSave(); await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await waitForSubmitSuccessToClear(textarea); await flushPendingTimers(); expect(focusSpy).toHaveBeenCalledTimes(1); @@ -498,7 +504,7 @@ describe("QuickEntryBox", () => { fireEvent.click(screen.getByRole("button", { name: "Create anyway" })); await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(textarea.value).toBe("")); + await waitForSubmitSuccessToClear(textarea); await flushPendingTimers(); expect(focusSpy).toHaveBeenCalledTimes(1); @@ -519,12 +525,33 @@ describe("QuickEntryBox", () => { fireEvent.keyDown(textarea, { key: "Enter" }); await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await waitForSubmitSuccessToClear(textarea); await flushPendingTimers(); expect(focusSpy).not.toHaveBeenCalled(); expect(document.activeElement).not.toBe(textarea); }); + it("preserves the draft without auto-focus when submission fails", async () => { + mockDesktopViewport(); + const addToast = vi.fn(); + const onCreate = vi.fn().mockRejectedValue(new Error("create failed")); + renderQuickEntryBox({ addToast, onCreate }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + const focusSpy = vi.spyOn(textarea, "focus"); + + fireEvent.change(textarea, { target: { value: "Failed task" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(textarea.value).toBe("Failed task")); + await flushPendingTimers(); + + expect(addToast).toHaveBeenCalledWith("create failed", "error"); + expect(focusSpy).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(textarea); + }); + it("does not auto-focus after Escape clears a non-empty draft", async () => { mockDesktopViewport(); renderQuickEntryBox({}); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 943d95d40e..45508cbdea 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -282,9 +282,12 @@ Quarantine the concurrency-sensitive workflow editor file under the deletion rat FNXC:DashboardTestQuarantine 2026-06-19-15:40: FN-6742 rescued session-cross-tab before the 2026-07-03 deletion ratchet by reproducing ENOTEMPTY in dashboard-api-quality-backfill and fixing the test-owned route/close-callback teardown seam. Keep it out of this exclude list so loaded API shards keep exercising cross-tab locking, beacon release, stale-lock expiry, SSE summaries, and stale-session cleanup. + +FNXC:DashboardTestQuarantine 2026-06-19-16:50: +FN-6743 rescued QuickEntryBox's third quarantine cycle by replacing the ref-gated post-submit focus effect with a resolved-submit focus trigger and broadening desktop/mobile submit coverage. +Keep QuickEntryBox out of this exclude list so the dashboard app lanes exercise Enter, Save, duplicate-confirmed creation, mobile non-focus, and failure-preserves-draft focus invariants. */ const quarantinedDashboardTests: string[] = [ - "app/components/__tests__/QuickEntryBox.test.tsx", "app/components/__tests__/WorkflowNodeEditor.test.tsx", ]; diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 584128b442..bf5d1448b8 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -1,11 +1,6 @@ { "$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.", "entries": [ - { - "file": "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx", - "reason": "FN-6697 local workspace `pnpm test` observed the post-submission focus restoration test fail in the broad dashboard app backfill shard, while a targeted rerun of QuickEntryBox with MailboxModal passed the QuickEntryBox assertions; quarantine the focus-timing flake instead of appeasing it while the terminal shortcut fix remains scoped.", - "quarantinedAt": "2026-06-19" - }, { "file": "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx", "reason": "FN-6726 local workspace `pnpm test` observed the duplicate-merge-seam template conflict assertion fail only in the broad dashboard components-b shard, while a targeted rerun of that exact test passed; quarantine the workflow editor concurrency flake instead of appeasing unrelated template insertion behavior while the Command Center token containment fix remains scoped.",