From 67ae2be0de6300c7df7d2e09d300aa99ea0a315a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 23:45:15 -0700 Subject: [PATCH] fix(chat): deliver mobile chat sends that silently dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mobile send failures with a shared symptom of "nothing happens": - Regular chat: the send button was dead to touch. The action lived only in onClick, but iOS suppresses the trailing synthetic click after preventDefault() in the touch sequence, so taps never sent. Fire the send from pointerdown/touchstart with a self-clearing dedupe latch (mirroring the QuickChat send button), keeping a single send per tap. - Quick chat: a queued message could strand in the composer — shown locally but never reaching the agent or the persisted session (so it also never appeared in regular chat). A stream that dropped without onDone/onError (e.g. mobile tab suspension) left the streaming flag stuck true, so every later send took the "queue while streaming" branch and was never flushed. On a queued send, detect the stale flag via the stream's connection state and the server's generation status, then tear down the dead stream and flush. Both paths covered by new tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fix-mobile-chat-send.md | 5 ++ .../dashboard/app/components/ChatView.tsx | 50 +++++++++++++++++++ .../components/__tests__/ChatView.test.tsx | 33 ++++++++++++ .../app/hooks/__tests__/useQuickChat.test.ts | 43 ++++++++++++++++ packages/dashboard/app/hooks/useQuickChat.ts | 46 ++++++++++++++++- 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-mobile-chat-send.md diff --git a/.changeset/fix-mobile-chat-send.md b/.changeset/fix-mobile-chat-send.md new file mode 100644 index 0000000000..6792465b47 --- /dev/null +++ b/.changeset/fix-mobile-chat-send.md @@ -0,0 +1,5 @@ +--- +"@fusion/dashboard": patch +--- + +Fix two mobile chat send failures. The regular chat send button was dead to touch because the action only ran on `onClick`, which iOS suppresses after `preventDefault()` in the touch sequence — it now fires from pointerdown/touchstart with a dedupe latch. Quick chat messages could strand in the composer (shown locally but never sent to the agent or persisted) when a dropped stream left the streaming flag stuck `true`; a queued send now detects the stale flag via the stream's connection state and the server's generation status, then recovers and flushes. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 64d1bea9e1..ca4313a9f8 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1097,6 +1097,14 @@ 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). + const handledSendTouchRef = useRef(false); + const handledSendTouchTimerRef = useRef(null); const tabletKeyboardSidebarVisibilityRef = useRef(null); const mode = useViewportMode(); const isMobile = mode === "mobile"; @@ -1930,6 +1938,37 @@ 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. + const markHandledSendTouch = useCallback(() => { + handledSendTouchRef.current = true; + if (handledSendTouchTimerRef.current != null) { + clearTimeout(handledSendTouchTimerRef.current); + } + handledSendTouchTimerRef.current = window.setTimeout(() => { + handledSendTouchRef.current = false; + handledSendTouchTimerRef.current = null; + }, 700); + }, []); + + // Consume the latch (cancelling its timer) so a trailing onClick bails once. + const consumeHandledSendTouch = useCallback(() => { + if (!handledSendTouchRef.current) return false; + handledSendTouchRef.current = false; + if (handledSendTouchTimerRef.current != null) { + clearTimeout(handledSendTouchTimerRef.current); + handledSendTouchTimerRef.current = null; + } + return true; + }, []); + + useEffect(() => () => { + if (handledSendTouchTimerRef.current != null) { + clearTimeout(handledSendTouchTimerRef.current); + } + }, []); + // Handle send message including pending attachment uploads. const handleSend = useCallback(() => { const trimmed = messageInput.trim(); @@ -2980,13 +3019,24 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView className="chat-input-send" onPointerDown={(event) => { if (event.pointerType && event.pointerType !== "mouse") { + // iOS suppresses the trailing click after this preventDefault, + // so fire the send here (deduped) rather than relying on onClick. event.preventDefault(); + if (handledSendTouchRef.current) return; + markHandledSendTouch(); + void handleSend(); } }} + onTouchStart={() => { + if (handledSendTouchRef.current) return; + markHandledSendTouch(); + void handleSend(); + }} onMouseDown={(event) => { event.preventDefault(); }} onClick={() => { + if (consumeHandledSendTouch()) return; void handleSend(); }} disabled={!messageInput.trim() && pendingAttachments.length === 0} diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index 34d47fcbfa..bdd2910dcf 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -1489,6 +1489,39 @@ describe("ChatView", () => { expect(sendMessage).toHaveBeenCalledWith("Hello world", []); }); + it("sends message on touch tap when the synthetic click is suppressed (mobile)", async () => { + const originalInnerWidth = window.innerWidth; + Object.defineProperty(window, "innerWidth", { value: 375, configurable: true }); + try { + const sendMessage = vi.fn(); + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + messages: [], + sendMessage, + }); + + await renderWithAct(); + + const textarea = screen.getByTestId("chat-input"); + await userEvent.type(textarea, "Touch hello"); + + const sendButton = screen.getByTestId("chat-send-btn"); + // iOS suppresses the trailing synthetic click after preventDefault() in the + // touch sequence, so the send must fire from the touch handlers. Both + // pointerdown (touch) and touchstart fire for one tap; the result must be a + // single send, not zero (bug) and not two (double-fire). + await act(async () => { + fireEvent.pointerDown(sendButton, { pointerType: "touch" }); + fireEvent.touchStart(sendButton); + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith("Touch hello", []); + } finally { + Object.defineProperty(window, "innerWidth", { value: originalInnerWidth, configurable: true }); + } + }); + it("clears room composer on Enter after successful room send", async () => { localStorage.setItem("fusion:chat-scope", "rooms"); const sendRoomMessage = vi.fn().mockResolvedValue(undefined); diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index 516833478f..47d2b96a37 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -152,6 +152,49 @@ describe("useQuickChat", () => { }); }); + it("recovers a queued send when the streaming flag is stuck after a dropped stream", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001" }); + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + // The server confirms no generation is actually in flight: the first + // stream died without delivering onDone/onError (e.g. mobile tab + // suspension dropped the SSE connection). + mockFetchChatSession.mockResolvedValue({ + session: { ...session, isGenerating: false }, + }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + // First send: the stream attaches but never completes and its socket is no + // longer OPEN (the tab was suspended), so isStreaming stays stuck true with + // a dead-but-non-null stream ref. + const closeSpy = vi.fn(); + mockStreamChatResponse.mockReturnValue({ close: closeSpy, isConnected: () => false }); + await act(async () => { + void result.current.sendMessage("First"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + + // Second send while the flag is stuck. It must NOT strand in the composer: + // the stale flag is detected (server says not generating) and the message + // is delivered to the agent. + await act(async () => { + void result.current.sendMessage("Second"); + }); + + await waitFor(() => { + expect(mockStreamChatResponse).toHaveBeenCalledTimes(2); + expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Second"); + }); + expect(result.current.pendingMessage).toBe(""); + }); + it("sendMessage returns a promise that resolves on stream completion", async () => { const session = makeSession({ id: "session-001", agentId: "agent-001" }); mockFetchResumeChatSession.mockResolvedValue({ session }); diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index 2ff17f6244..b8fb60801d 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -214,7 +214,7 @@ export function useQuickChat( const [pendingMessage, setPendingMessage] = useState(""); // Stream connection ref for cleanup - const streamRef = useRef<{ close: () => void } | null>(null); + const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const lastAttachedGenerationRef = useRef<{ sessionId: string; replayFromEventId: number | null } | null>(null); const cancelledByUserRef = useRef(false); const cancelStreamingFlushesRef = useRef<(() => void) | null>(null); @@ -813,6 +813,47 @@ export function useQuickChat( } }, [attachIfGenerating, projectId, refreshSessions, reloadMessages]); + // A stream that dropped without firing onDone/onError — commonly a mobile tab + // suspension severing the SSE connection — leaves isStreamingRef stuck `true` + // with a dead-but-non-null streamRef. Every later send then takes the "queue + // while streaming" branch below and strands in the composer: the message + // shows locally but never reaches the agent or the persisted session (so it + // also never appears in regular chat). When a send is queued this way, + // confirm with the server whether a generation is truly in flight; if not, + // the flag is stale, so tear down the dead stream and flush the queued send. + const recoverQueuedSendIfStreamStale = useCallback(async (sessionId: string) => { + // Fast path: an OPEN stream socket means a healthy in-flight generation, so + // leave the message queued for its onDone/onError to flush. Only a dead or + // missing stream needs recovery — this also avoids a server round-trip (and + // its side effects) in the common "queued while genuinely streaming" case. + if (streamRef.current?.isConnected()) { + return; + } + try { + const { session: refreshed } = await fetchChatSession(sessionId, projectId); + if ( + // Genuinely generating server-side: the live stream will flush. + refreshed.isGenerating || + // A stream reconnected while we were awaiting: defer to it. + streamRef.current?.isConnected() || + activeSessionRef.current?.id !== sessionId || + pendingMessageRef.current.trim().length === 0 + ) { + return; + } + if (streamRef.current) { + streamRef.current.close(); + streamRef.current = null; + } + setIsStreaming(false); + isStreamingRef.current = false; + flushPendingMessage(); + } catch { + // Leave the queued message; another trigger (visibility resume, manual + // resend, stream completion) can still deliver it. + } + }, [projectId, flushPendingMessage]); + /** * Send a message using SSE streaming. * @param content message text content @@ -858,6 +899,7 @@ export function useQuickChat( pendingMessageRef.current = content; setPendingMessage(content); setPersistedPendingChatMessage(activeSession.id, content); + void recoverQueuedSendIfStreamStale(activeSession.id); return Promise.resolve(); } @@ -988,7 +1030,7 @@ export function useQuickChat( void completionPromise.catch(() => {}); return completionPromise; }, - [activeSession, projectId, addToast, reloadMessages, reconnectSessionSilently, flushPendingMessage], + [activeSession, projectId, addToast, reloadMessages, reconnectSessionSilently, flushPendingMessage, recoverQueuedSendIfStreamStale], ); sendMessageRef.current = sendMessage;