fix(chat): stop quick chat mobile send button firing twice per tap
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) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-quick-chat-double-fire-send.md
Normal file
5
.changeset/fix-quick-chat-double-fire-send.md
Normal file
@@ -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.
|
||||
@@ -1071,6 +1071,10 @@ export function QuickChatFAB({
|
||||
const shouldAutoFocusComposerRef = useRef(false);
|
||||
const handledMobileActionRef = useRef(false);
|
||||
const handledMobileActionTimerRef = useRef<ReturnType<typeof setTimeout> | 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();
|
||||
|
||||
@@ -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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user