diff --git a/.changeset/fn-5852-queued-message-stale-flush.md b/.changeset/fn-5852-queued-message-stale-flush.md new file mode 100644 index 0000000000..e1bc0419d4 --- /dev/null +++ b/.changeset/fn-5852-queued-message-stale-flush.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": patch +--- + +Stop queued chat messages from disappearing after back-navigation while the assistant is still responding (GitHub #1279). + +Re-entering a chat restored the queued follow-up and immediately flushed it based on the client's local `isGenerating` flag — which is stale mid-generation (it is a route-level enrichment the `chat:session:updated` SSE payload lacks). The premature send aborted the live generation server-side and could lose the queued message entirely, since its persisted copy was deleted before the send. + +The restore path in both Chat and Quick Chat now confirms with the server before flushing: if a generation is still in flight it re-attaches to the stream and lets completion deliver the queued message; the message is sent immediately only when the server reports no active generation. On a failed check the queued bubble is kept for a later flush trigger. diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 202f1ae2e9..852209563b 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -2088,6 +2088,70 @@ describe("useChat", () => { }); }); + it("does not flush a restored queued message while the server still reports an in-flight generation", async () => { + // Reproduces FN-5852 back-navigation loss: the sessions-list entry has a + // stale falsy isGenerating (it is a route-level enrichment that the + // chat:session:updated SSE payload lacks), while the server is actually + // still generating. The restored queued message must NOT be flushed from + // local state alone — doing so aborts the live generation server-side. + const sessionA = makeSession({ id: "session-001", agentId: "agent-001" }); + mockFetchChatSessions.mockResolvedValue({ sessions: [sessionA] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchChatSession.mockResolvedValue({ + session: { + ...sessionA, + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }, + }); + + const attachHandlers: Array[1]> = []; + mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => { + attachHandlers.push(nextHandlers); + return { close: vi.fn(), isConnected: () => true }; + }); + + localStorage.setItem(getChatPendingMessageKey("session-001")!, "Queued follow-up"); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + // The queued message is restored and the authoritative session fetch + // reveals the in-flight generation, so the hook attaches instead of + // flushing. + await waitFor(() => { + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(result.current.isStreaming).toBe(true); + }); + + expect(mockStreamChatResponse).not.toHaveBeenCalled(); + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up"); + + // Once the attached generation completes, the queued message flushes. + act(() => { + attachHandlers[0]?.onDone?.({ messageId: "msg-001" }); + }); + + await waitFor(() => { + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + expect(mockStreamChatResponse.mock.calls[0]?.[0]).toBe("session-001"); + expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up"); + expect(result.current.pendingMessage).toBe(""); + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull(); + }); + }); + it("preserves queued messages across session switches and rehydrates them when returning", async () => { const sessionA = { ...makeSession({ id: "session-001", agentId: "agent-001" }), diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index 487693a913..5c4cd82b40 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -972,6 +972,61 @@ describe("useQuickChat", () => { }); }); + it("does not flush a restored queued message while the server still reports an in-flight generation", async () => { + // Mirrors the useChat FN-5852 regression: the locally-held session has a + // stale falsy isGenerating, but the server is still generating. The + // restored queued message must wait for the authoritative fetch instead + // of flushing immediately (which would abort the live generation). + const staleSessionA = makeSession({ id: "session-a", agentId: "agent-001" }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchChatSession.mockResolvedValue({ + session: { + ...staleSessionA, + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }, + }); + + const attachHandlers: Array[1]> = []; + mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => { + attachHandlers.push(nextHandlers); + return { close: vi.fn(), isConnected: () => true }; + }); + + localStorage.setItem(getChatPendingMessageKey("session-a")!, "Queued follow-up"); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.selectSession(staleSessionA); + }); + + await waitFor(() => { + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(result.current.isStreaming).toBe(true); + }); + + expect(mockStreamChatResponse).not.toHaveBeenCalled(); + expect(localStorage.getItem(getChatPendingMessageKey("session-a"))).toBe("Queued follow-up"); + + // Once the attached generation completes, the queued message flushes. + act(() => { + attachHandlers[0]?.onDone?.({ messageId: "msg-001" }); + }); + + await waitFor(() => { + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + expect(mockStreamChatResponse.mock.calls[0]?.[0]).toBe("session-a"); + expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up"); + expect(result.current.pendingMessage).toBe(""); + expect(localStorage.getItem(getChatPendingMessageKey("session-a"))).toBeNull(); + }); + }); + it("pre-session queueing does not write a null localStorage key", async () => { const session = makeSession({ id: "session-pre", agentId: "agent-001" }); mockFetchResumeChatSession.mockResolvedValueOnce({ session }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 483181ce01..c6830044a2 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -683,17 +683,44 @@ export function useChat( pendingMessageRef.current = restoredPendingMessage; setPendingMessage(restoredPendingMessage); - queueMicrotask(() => { - if ( - activeSessionRef.current?.id === sessionId && - pendingMessageRef.current.trim().length > 0 && - !isStreamingRef.current && - !streamRef.current - ) { - flushPendingMessage(); - } - }); - }, [activeSession?.id, flushPendingMessage]); + // Flush only once the server confirms no generation is in flight. The + // local sessions list can hold a stale falsy `isGenerating` (it is a + // route-level enrichment that the chat:session:updated SSE payload + // lacks), so flushing from local state alone fires a send that aborts a + // live generation server-side and can lose the queued message (FN-5852). + let cancelled = false; + void fetchChatSession(sessionId, projectId) + .then(({ session: refreshedSession }) => { + if ( + cancelled || + activeSessionRef.current?.id !== sessionId || + pendingMessageRef.current.trim().length === 0 + ) { + return; + } + + if (refreshedSession.isGenerating) { + // Still generating: attach (if not already) and let the stream's + // onDone/onError flush the queued message. + if (!streamRef.current) { + attachIfGenerating(sessionId, refreshedSession.inFlightGeneration); + } + return; + } + + if (!isStreamingRef.current && !streamRef.current) { + flushPendingMessage(); + } + }) + .catch(() => { + // Keep the restored bubble; another flush trigger (stream + // completion, visibility resume, manual send) will deliver it. + }); + + return () => { + cancelled = true; + }; + }, [activeSession?.id, attachIfGenerating, flushPendingMessage, projectId]); // Create a new session const createSession = useCallback( diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index 6172154961..499f129582 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -656,17 +656,44 @@ export function useQuickChat( pendingMessageRef.current = restoredPendingMessage; setPendingMessage(restoredPendingMessage); - queueMicrotask(() => { - if ( - activeSessionRef.current?.id === sessionId && - pendingMessageRef.current.trim().length > 0 && - !isStreamingRef.current && - !streamRef.current - ) { - void flushPendingMessage(); - } - }); - }, [activeSession?.id, flushPendingMessage]); + // Flush only once the server confirms no generation is in flight. The + // local session snapshot can hold a stale falsy `isGenerating` (it is a + // route-level enrichment that the chat:session:updated SSE payload + // lacks), so flushing from local state alone fires a send that aborts a + // live generation server-side and can lose the queued message (FN-5852). + let cancelled = false; + void Promise.resolve(fetchChatSession(sessionId, projectId)) + .then(({ session: refreshedSession }) => { + if ( + cancelled || + activeSessionRef.current?.id !== sessionId || + pendingMessageRef.current.trim().length === 0 + ) { + return; + } + + if (refreshedSession.isGenerating) { + // Still generating: attach (if not already) and let the stream's + // onDone/onError flush the queued message. + if (!streamRef.current) { + attachIfGenerating(sessionId, refreshedSession.inFlightGeneration); + } + return; + } + + if (!isStreamingRef.current && !streamRef.current) { + void flushPendingMessage(); + } + }) + .catch(() => { + // Keep the restored bubble; another flush trigger (stream + // completion, visibility resume, manual send) will deliver it. + }); + + return () => { + cancelled = true; + }; + }, [activeSession?.id, attachIfGenerating, flushPendingMessage, projectId]); const startModelChat = useCallback( async (modelProvider: string, modelId: string) => {