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;