From 484f0163344623d351e86a27bb23685e7114dbf4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 08:54:24 -0700 Subject: [PATCH] FN-6576: align chat send touch dedupe Align the direct and room chat mobile send controls around the same pointer/touch dedupe contract. - Add a per-gesture touch action latch separate from the trailing synthetic-click latch. - Apply the two-latch handling to direct send, room send, and send-to-stop transitions. - Cover repeated iOS taps, pointer/touch/click dedupe, and stop-button swap behavior in ChatView tests. - Document the shared mobile send dedupe behavior for direct and room chat. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/ChatView.tsx | 70 ++++++++--- .../components/__tests__/ChatView.rooms.test.tsx | 30 +++++ .../app/components/__tests__/ChatView.test.tsx | 132 +++++++++++++++++++++ 4 files changed, 215 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-6576 Fusion-Task-Lineage: b8ef427f-99f0-48d8-8cb8-d72ac352bdeb --- docs/dashboard-guide.md | 2 +- .../dashboard/app/components/ChatView.tsx | 70 +++++++--- .../__tests__/ChatView.rooms.test.tsx | 30 ++++ .../components/__tests__/ChatView.test.tsx | 132 ++++++++++++++++++ 4 files changed, 215 insertions(+), 19 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5178f30a6c..dc91ec43e4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -271,7 +271,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are - Submitting the room composer calls `rooms.sendRoomMessage(...)`, which immediately inserts a temporary local user message and then posts to `POST /api/chat/rooms/:id/messages`. - The room composer clears immediately when send is dispatched so the user gets instant feedback; on success the optimistic message is reconciled with persisted server data and the transcript is refreshed to authoritative history. - On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing. -- On mobile, the room composer send button uses the same touch/pointer dedupe as direct chat: one tap dispatches exactly one room send even when the browser emits pointer, touch, and click events differently across iOS and Android. +- On mobile, the room and direct composer send buttons use a two-latch touch/pointer dedupe: pointer/touch events claim only the current gesture, while a separate click latch consumes any trailing synthetic click. One tap dispatches exactly one send, a second iOS tap within the suppressed-click window still sends, and a send-to-stop button swap does not accidentally press stop. - The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`. - Room responders can intentionally stay silent by returning the `__SKIP__` sentinel; that sentinel is treated as a no-op and is never persisted, emitted over SSE, or rendered in room transcripts. - If room replies cannot be generated (for example no resolvable responders or all responders fail), the POST fails with an API error (HTTP 502) instead of silently returning only the user message. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 898bc168f6..5b37c1b2e6 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1140,14 +1140,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView const mentionCursorPosRef = useRef(0); const copyFeedbackTimeoutsRef = useRef>(new Map()); const roomSendInFlightRef = useRef(false); - // Mobile send-button tap latch. iOS suppresses the trailing synthetic click - // after preventDefault() in the touch sequence, so the send must fire from - // pointerdown/touchstart. This latch dedupes the multiple events of one tap - // (pointerdown + touchstart, plus any surviving click) into a single send, - // and self-clears on a timer so a suppressed click can't leave it stuck true - // (which would swallow the next real tap and make the button look dead). + /* + FNXC:ChatSendDedupe 2026-06-17-08:36: + FN-6576 refines FN-6563 by matching QuickChatFAB's two-latch touch contract: pointerdown/touchstart claim a per-input-task gesture so one mobile tap sends exactly once, while the separate 700ms latch is consumed only by a trailing click. A suppressed iOS click must never leave the long latch blocking the next tap; a send-to-stop DOM swap must consume the trailing click without swallowing a genuine later stop tap. + */ const handledSendTouchRef = useRef(false); const handledSendTouchTimerRef = useRef(null); + const touchActionGestureRef = useRef(false); const mode = useViewportMode(); const isMobile = mode === "mobile"; const isTablet = mode === "tablet"; @@ -1957,9 +1956,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView }); }, [activeDraftKey]); - // Mark that a touch gesture already triggered the send so the trailing - // onClick (if it survives) bails. Auto-resets so a suppressed click never - // leaves the latch stuck. + // Mark that a mobile pointer/touch handler already performed the action so + // the trailing onClick (if it survives) bails. This long latch is intentionally + // never consulted by pointerdown/touchstart, because iOS may suppress the + // click that would consume it. const markHandledSendTouch = useCallback(() => { handledSendTouchRef.current = true; if (handledSendTouchTimerRef.current != null) { @@ -1971,6 +1971,18 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView }, 700); }, []); + // Claim one input task's touch gesture. Real mobile taps can dispatch both + // pointerdown and touchstart before React flushes state; only the first should + // run the action, and the claim must clear before the next tap. + const beginTouchActionGesture = useCallback(() => { + if (touchActionGestureRef.current) return false; + touchActionGestureRef.current = true; + window.setTimeout(() => { + touchActionGestureRef.current = false; + }, 0); + return true; + }, []); + // Consume the latch (cancelling its timer) so a trailing onClick bails once. const consumeHandledSendTouch = useCallback(() => { if (!handledSendTouchRef.current) return false; @@ -3092,7 +3104,27 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView {isStreaming ? (