From fd6caaa338a88aa582e6855ca57583bb27c6b623 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 14 Jun 2026 00:34:17 -0700 Subject: [PATCH] fix(chat): stop quick chat mobile send button firing twice per tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real touch tap dispatches both pointerdown and touchstart, and the quick chat send (and stop) buttons ran their action on each event. Because React had not flushed the composer clear between the two synchronous handlers, both saw the same input and fired handleSendMessage — and the hook's second send closed the first's freshly-opened stream and re-POSTed, which could drop the agent's response (notably for the first message after a response completed). Add a tap-scoped guard (cleared after the current input task) so only the first of the paired pointerdown/touchstart events performs the action. This is kept separate from the 700ms onClick latch — which is shared between the send and stop buttons — so a stop tap right after a send is never swallowed. The earlier component test masked this because fireEvent flushes React state between calls; the regression test now dispatches the full tap sequence in one act() to mirror the device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fix-quick-chat-double-fire-send.md | 5 +++ .../dashboard/app/components/QuickChatFAB.tsx | 24 ++++++++++++++ .../__tests__/QuickChatFAB.test.tsx | 31 ++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-quick-chat-double-fire-send.md diff --git a/.changeset/fix-quick-chat-double-fire-send.md b/.changeset/fix-quick-chat-double-fire-send.md new file mode 100644 index 0000000000..517d19e144 --- /dev/null +++ b/.changeset/fix-quick-chat-double-fire-send.md @@ -0,0 +1,5 @@ +--- +"@fusion/dashboard": patch +--- + +Fix sporadic quick chat send failures on mobile (notably the first message after a response). A real touch tap dispatches both `pointerdown` and `touchstart`, and the quick chat send button ran its action on each — firing `handleSendMessage` twice per tap. Because React had not yet flushed the composer clear between the two events, both reads saw the same text and sent, and the hook's second send closed the first's freshly-opened stream and re-POSTed, which could drop the response. The send and stop buttons now claim a single action per tap so only the first of the paired events fires. diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index 2590c6ef5d..5797101c91 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -1071,6 +1071,10 @@ export function QuickChatFAB({ const shouldAutoFocusComposerRef = useRef(false); const handledMobileActionRef = useRef(false); const handledMobileActionTimerRef = useRef | null>(null); + // Dedupe pointerdown vs touchstart within a single tap: a real touch fires + // both, and each handler runs its action before React flushes the input + // clear, so without this the action runs twice per tap. + const touchActionGestureRef = useRef(false); const preserveComposerFocusRef = useRef(false); // Always-mounted offscreen input used to claim the iOS soft keyboard // synchronously inside the FAB click gesture, before the real composer @@ -1992,6 +1996,22 @@ export function QuickChatFAB({ }, 700); }, []); + // Claim a touch gesture for a single action. A real touch tap dispatches both + // pointerdown and touchstart, and each handler runs before React flushes the + // composer-clear, so both would otherwise fire the action (double send, or a + // second send that aborts the first's freshly-opened stream). The first event + // of the tap claims; the second bails. The claim auto-clears after the current + // input task so a later tap — or a different button (e.g. stop right after + // send) — starts fresh, unlike the 700ms onClick latch above. + const beginTouchActionGesture = useCallback(() => { + if (touchActionGestureRef.current) return false; + touchActionGestureRef.current = true; + setTimeout(() => { + touchActionGestureRef.current = false; + }, 0); + return true; + }, []); + // If a mobile handler already ran this gesture's action, consume the latch // (and cancel its timer) so the trailing onClick bails without double-firing. const consumeHandledMobileAction = useCallback(() => { @@ -3086,6 +3106,7 @@ export function QuickChatFAB({ if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); if (event.pointerType && event.pointerType !== "mouse") { + if (!beginTouchActionGesture()) return; markHandledMobileAction(); stopStreaming(); } @@ -3093,6 +3114,7 @@ export function QuickChatFAB({ onTouchStart={(event) => { if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); + if (!beginTouchActionGesture()) return; markHandledMobileAction(); stopStreaming(); }} @@ -3117,6 +3139,7 @@ export function QuickChatFAB({ if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); if (event.pointerType && event.pointerType !== "mouse") { + if (!beginTouchActionGesture()) return; markHandledMobileAction(); markPreserveComposerFocus(); focusComposerInput(); @@ -3126,6 +3149,7 @@ export function QuickChatFAB({ onTouchStart={(event) => { if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; event.preventDefault(); + if (!beginTouchActionGesture()) return; markHandledMobileAction(); markPreserveComposerFocus(); focusComposerInput(); diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 0a519daa50..f9ce5670ff 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { Agent } from "../../api"; import type { ChatSession } from "@fusion/core"; import * as apiModule from "../../api"; @@ -855,6 +855,35 @@ describe("QuickChatFAB session-first UX", () => { } }); + it("Android send fires exactly once for a full pointerdown+touchstart+click tap", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(false); + mockStreamChatResponse.mockImplementation(() => ({ close: vi.fn(), isConnected: () => true })); + try { + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + fireEvent.change(input, { target: { value: "Hello" } }); + + const sendButton = screen.getByTestId("quick-chat-send"); + // Real Android tap dispatches pointerdown + touchstart + click within one + // task, with no React flush between them (unlike separate fireEvent calls). + // Dispatch them in a single act() so state batching mirrors the device. + await act(async () => { + sendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" })); + sendButton.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true })); + sendButton.dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); + }); + + await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1)); + } finally { + isIOSSpy.mockRestore(); + } + }); + it("FN-6301: Android mobile composer touchstart leaves native focus uncanceled", async () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); window.dispatchEvent(new Event("resize"));