From 8cf32d5b2b15720a349557eead5fbd9a64200dbb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 14 Jun 2026 00:06:34 -0700 Subject: [PATCH] fix(chat): add delivery watchdog for stranded quick chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the quick chat send fix. The send-time stale-stream recovery only fires when a message is queued while the streaming flag is already stuck, and the pre-session flush effect fires once on session activation and bails permanently if a stream ref is lingering. Either gap leaves a queued message stranded idle in the composer — shown locally but never sent to the agent or persisted (so also absent from regular chat). Add a delivery watchdog: whenever a message stays pending under an active session, re-confirm after a short delay and force-deliver it if the server reports no generation in flight and no live stream is connected. This is a catch-all backstop independent of which targeted flush trigger bailed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fix-mobile-chat-send.md | 2 +- .../app/hooks/__tests__/useQuickChat.test.ts | 48 ++++++++++++++++ packages/dashboard/app/hooks/useQuickChat.ts | 56 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-mobile-chat-send.md b/.changeset/fix-mobile-chat-send.md index 6792465b47..c52f3cb502 100644 --- a/.changeset/fix-mobile-chat-send.md +++ b/.changeset/fix-mobile-chat-send.md @@ -2,4 +2,4 @@ "@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. +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 queued message's delivery trigger bailed — a dropped stream leaving the streaming flag stuck `true`, or a stream that looked healthy when queued but then stalled. A queued send now detects a stale flag at send time via the stream's connection state and the server's generation status, and a delivery watchdog re-confirms any message that stays pending and force-delivers it once no generation is actually in flight. diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index 47d2b96a37..a685712928 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -195,6 +195,54 @@ describe("useQuickChat", () => { expect(result.current.pendingMessage).toBe(""); }); + it("delivers a queued message via the watchdog when a stream stalls after the send was queued", async () => { + vi.useFakeTimers(); + try { + const session = makeSession({ id: "session-001", agentId: "agent-001" }); + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + // Server confirms nothing is generating: the stream died after we queued. + mockFetchChatSession.mockResolvedValue({ + session: { ...session, isGenerating: false }, + }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + // First send attaches a stream that is connected at send time but never + // completes (onDone/onError never fire). + let connected = true; + mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => connected }); + await act(async () => { + void result.current.sendMessage("First"); + }); + + // Second send while streaming: queued. The send-time recovery sees the + // stream still connected, so it correctly leaves the message queued to be + // flushed by the stream's onDone — which never arrives. + await act(async () => { + void result.current.sendMessage("Second"); + }); + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + + // The stream goes dead. The watchdog re-confirms after its delay and, since + // the server reports no generation in flight, delivers the queued message. + connected = false; + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(mockStreamChatResponse).toHaveBeenCalledTimes(2); + expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Second"); + expect(result.current.pendingMessage).toBe(""); + } finally { + vi.useRealTimers(); + } + }); + 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 b8fb60801d..3808eca0c6 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -190,6 +190,12 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo { }; } +// Backstop delay before a still-pending queued message is re-confirmed and +// force-delivered. Long enough to let the targeted flush paths (pre-session +// activation, stream completion) deliver first; short enough that a stranded +// message reaches the agent quickly. +const QUEUED_MESSAGE_DELIVERY_WATCHDOG_MS = 1500; + /** * Hook for the QuickChatFAB component. * Provides chat session management and SSE streaming for real-time AI responses. @@ -1116,6 +1122,56 @@ export function useQuickChat( flushPendingMessage(); }, [activeSession, flushPendingMessage]); + // Delivery backstop for queued messages. The targeted flush triggers + // (pre-session activation, stream onDone/onError, send-time stale recovery) + // each fire once on a specific transition. If the relevant one bails — a + // lingering stream ref at session activation, or a stream that looked healthy + // when we chose to wait for its onDone but then died without firing it — the + // queued message strands in the composer forever: shown locally but never + // sent to the agent or persisted (so also absent from regular chat). Whenever + // a message stays pending under an active session, re-confirm after a short + // delay and deliver it if no generation is actually in flight server-side. + useEffect(() => { + const sessionId = activeSession?.id; + if (!sessionId || pendingMessage.trim().length === 0) { + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + if (cancelled || pendingMessageRef.current.trim().length === 0) { + return; + } + void fetchChatSession(sessionId, projectId) + .then(({ session: refreshed }) => { + if ( + cancelled || + // A real generation is in flight: its stream will flush the queue. + refreshed.isGenerating || + // A live stream reconnected while we waited: 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(() => { + // Keep the queued message; a later trigger can still deliver it. + }); + }, QUEUED_MESSAGE_DELIVERY_WATCHDOG_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [activeSession?.id, pendingMessage, projectId, flushPendingMessage]); + // Cleanup on unmount useEffect(() => { return () => {