From fab2377fd160c9647851daf4b6311986686822e0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 22:42:19 -0700 Subject: [PATCH] FN-5921: persist queued chat messages across navigation Keep queued chat composer state when users move between sessions and only clear it when the session is truly discarded. - stop clearing persisted pending chat messages during session switches in useChat and useQuickChat - clear persisted queued messages only when creating a fresh session or archiving/deleting an existing session - add regression coverage for navigation, reselection, pre-session queueing, and cleanup behavior across chat and quick chat hooks Files changed: .../dashboard/app/hooks/__tests__/useChat.test.ts | 246 ++++++++++++++++++++- .../app/hooks/__tests__/useQuickChat.test.ts | 150 +++++++++++++ packages/dashboard/app/hooks/useChat.ts | 9 +- packages/dashboard/app/hooks/useQuickChat.ts | 5 +- 4 files changed, 396 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-5921 Fusion-Task-Lineage: 8916f862-77f5-4d5b-be9a-412e8a71a47d --- .../app/hooks/__tests__/useChat.test.ts | 254 +++++++++++++++++- .../app/hooks/__tests__/useQuickChat.test.ts | 150 +++++++++++ packages/dashboard/app/hooks/useChat.ts | 9 +- packages/dashboard/app/hooks/useQuickChat.ts | 5 +- 4 files changed, 400 insertions(+), 18 deletions(-) diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 269be8ca0..202f1ae2e 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -2027,13 +2027,81 @@ describe("useChat", () => { }); }); - it("selectSession clears pending queued message state", async () => { - const sessionA = makeSession({ id: "session-001", agentId: "agent-001" }); + it("preserves queued message localStorage entry when navigating away and restores it on return", async () => { + const sessionA = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }; + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + }); + + act(() => { + result.current.sendMessage("Queued follow-up"); + }); + + await waitFor(() => { + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up"); + }); + + act(() => { + result.current.selectSession(""); + }); + + await waitFor(() => { + expect(result.current.activeSession).toBeNull(); + expect(result.current.pendingMessage).toBe(""); + expect(result.current.isStreaming).toBe(false); + }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up"); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-001"); + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(result.current.isStreaming).toBe(true); + }); + }); + + it("preserves queued messages across session switches and rehydrates them when returning", async () => { + const sessionA = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }; const sessionB = makeSession({ id: "session-002", agentId: "agent-002" }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); - - mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); const { result } = renderHook(() => useChat("proj-123")); @@ -2045,14 +2113,6 @@ describe("useChat", () => { result.current.selectSession("session-001"); }); - await waitFor(() => { - expect(result.current.activeSession?.id).toBe("session-001"); - }); - - act(() => { - result.current.sendMessage("First"); - }); - await waitFor(() => { expect(result.current.isStreaming).toBe(true); }); @@ -2070,9 +2130,41 @@ describe("useChat", () => { }); await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-002"); expect(result.current.pendingMessage).toBe(""); expect(result.current.isStreaming).toBe(false); }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up"); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-001"); + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(result.current.isStreaming).toBe(true); + }); + }); + + it("clearPendingMessage is safe without an active session and leaves localStorage untouched", async () => { + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123"); + }); + + expect(() => { + act(() => { + result.current.clearPendingMessage(); + result.current.selectSession(""); + result.current.sendMessage("No session yet"); + }); + }).not.toThrow(); + + expect(localStorage.getItem("fusion:chat-pending:null")).toBeNull(); + expect(localStorage.getItem("fusion:chat-pending:undefined")).toBeNull(); }); it("clearPendingMessage clears pending message and removes persisted queue entry", async () => { @@ -2120,6 +2212,144 @@ describe("useChat", () => { expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull(); }); + it("createSession removes the prior session's persisted queued message", async () => { + const existingSession = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }; + const newSession = makeSession({ id: "session-002", agentId: "agent-001", title: "Fresh" }); + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [existingSession] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + mockCreateChatSession.mockResolvedValueOnce({ session: newSession }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + }); + + act(() => { + result.current.sendMessage("Queued follow-up"); + }); + + await waitFor(() => { + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up"); + }); + + await act(async () => { + await result.current.createSession({ agentId: "agent-001", title: "Fresh" }); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-002"); + }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull(); + }); + + it("archiveSession removes the archived session's persisted queued message", async () => { + const session = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }; + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + }); + + act(() => { + result.current.sendMessage("Queued follow-up"); + }); + + await waitFor(() => { + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up"); + }); + + await act(async () => { + await result.current.archiveSession("session-001"); + }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull(); + }); + + it("deleteSession removes the deleted session's persisted queued message", async () => { + const session = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }; + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + }); + + act(() => { + result.current.sendMessage("Queued follow-up"); + }); + + await waitFor(() => { + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up"); + }); + + await act(async () => { + await result.current.deleteSession("session-001"); + }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull(); + }); + it("restored queued message auto-sends once after generation already completed", async () => { const session = makeSession({ id: "session-001", agentId: "agent-001" }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index c1c8cba11..487693a91 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -848,6 +848,156 @@ describe("useQuickChat", () => { expect(localStorage.getItem(getChatPendingMessageKey("session-existing"))).toBeNull(); }); + it("preserves queued quick-chat messages across switchSession and restores them when returning", async () => { + const sessionA = { + ...makeSession({ id: "session-a", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }; + const sessionB = makeSession({ id: "session-b", agentId: "agent-002" }); + + mockFetchResumeChatSession + .mockResolvedValueOnce({ session: sessionA }) + .mockResolvedValueOnce({ session: sessionB }) + .mockResolvedValueOnce({ session: sessionA }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-a"); + expect(result.current.isStreaming).toBe(true); + }); + + act(() => { + void result.current.sendMessage("Queued follow-up"); + }); + + await waitFor(() => { + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(localStorage.getItem(getChatPendingMessageKey("session-a"))).toBe("Queued follow-up"); + }); + + await act(async () => { + await result.current.switchSession("agent-002"); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-b"); + expect(result.current.pendingMessage).toBe(""); + expect(result.current.isStreaming).toBe(false); + }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-a"))).toBe("Queued follow-up"); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-a"); + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(result.current.isStreaming).toBe(true); + }); + }); + + it("preserves queued quick-chat messages across selectSession and restores them when reselecting", async () => { + const sessionA = { + ...makeSession({ id: "session-a", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + streamingText: "partial", + streamingThinking: "", + toolCalls: [], + }, + }; + const sessionB = makeSession({ id: "session-b", agentId: "agent-002" }); + + mockFetchChatSession + .mockResolvedValueOnce({ session: sessionA }) + .mockResolvedValueOnce({ session: sessionB }) + .mockResolvedValueOnce({ session: sessionA }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.selectSession(sessionA); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-a"); + expect(result.current.isStreaming).toBe(true); + }); + + act(() => { + void result.current.sendMessage("Queued follow-up"); + }); + + await waitFor(() => { + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(localStorage.getItem(getChatPendingMessageKey("session-a"))).toBe("Queued follow-up"); + }); + + await act(async () => { + await result.current.selectSession(sessionB); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-b"); + expect(result.current.pendingMessage).toBe(""); + expect(result.current.isStreaming).toBe(false); + }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-a"))).toBe("Queued follow-up"); + + await act(async () => { + await result.current.selectSession(sessionA); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-a"); + expect(result.current.pendingMessage).toBe("Queued follow-up"); + expect(result.current.isStreaming).toBe(true); + }); + }); + + it("pre-session queueing does not write a null localStorage key", async () => { + const session = makeSession({ id: "session-pre", agentId: "agent-001" }); + mockFetchResumeChatSession.mockResolvedValueOnce({ session }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + let sendPromise!: Promise; + await act(async () => { + sendPromise = result.current.sendMessage("Hello before session ready"); + }); + + expect(localStorage.getItem(getChatPendingMessageKey("session-pre"))).toBeNull(); + expect(localStorage.getItem("fusion:chat-pending:null")).toBeNull(); + expect(localStorage.getItem("fusion:chat-pending:undefined")).toBeNull(); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + await expect(sendPromise).resolves.toBeUndefined(); + expect(localStorage.getItem(getChatPendingMessageKey("session-pre"))).toBeNull(); + expect(localStorage.getItem("fusion:chat-pending:null")).toBeNull(); + expect(localStorage.getItem("fusion:chat-pending:undefined")).toBeNull(); + }); + it("sending during streaming queues message without warning toast", async () => { const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" }); const addToast = vi.fn(); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index d9354d060..483181ce0 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -487,7 +487,6 @@ export function useChat( const resetTransientComposerState = useCallback(() => { cancelStreamingFlushesRef.current?.(); cancelStreamingFlushesRef.current = null; - removePersistedPendingChatMessage(activeSessionRef.current?.id); pendingMessageRef.current = ""; setPendingMessage(""); setStreamingText(""); @@ -604,10 +603,6 @@ export function useChat( if (id && currentActiveSessionId === id && !sessionOverride) { return; } - if (currentActiveSessionId && currentActiveSessionId !== id) { - removePersistedPendingChatMessage(currentActiveSessionId); - } - // Close any existing stream if (streamRef.current) { streamRef.current.close(); @@ -703,6 +698,7 @@ export function useChat( // Create a new session const createSession = useCallback( async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => { + const previousSessionId = activeSessionRef.current?.id; const data = await apiCreateChatSession(input, projectId); if (streamRef.current) { @@ -726,6 +722,7 @@ export function useChat( return [newSession, ...prev]; }); + removePersistedPendingChatMessage(previousSessionId); resetTransientComposerState(); selectSession(newSession.id, newSession); @@ -737,6 +734,7 @@ export function useChat( // Archive a session const archiveSession = useCallback( async (id: string) => { + removePersistedPendingChatMessage(id); await updateChatSession(id, { status: "archived" }, projectId); // Remove from sessions list setSessions((prev) => prev.filter((s) => s.id !== id)); @@ -753,6 +751,7 @@ export function useChat( // Delete a session const deleteSession = useCallback( async (id: string) => { + removePersistedPendingChatMessage(id); // Close stream if active if (activeSession?.id === id && streamRef.current) { streamRef.current.close(); diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index b0b530873..617215496 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -530,7 +530,8 @@ export function useQuickChat( const resetTransientComposerState = useCallback(() => { cancelStreamingFlushesRef.current?.(); cancelStreamingFlushesRef.current = null; - removePersistedPendingChatMessage(activeSessionRef.current?.id); + // Intentionally leave persisted queued messages alone here so navigation + // and session switching can rehydrate them on return. pendingMessageRef.current = ""; setPendingMessage(""); queuedPreSessionCompletionRef.current?.resolve(); @@ -697,6 +698,8 @@ export function useQuickChat( } lastAttachedGenerationRef.current = null; + // Fresh-session reset is a real dismissal of the old session queue. + removePersistedPendingChatMessage(activeSessionRef.current?.id); resetTransientComposerState(); setMessages([]); setActiveSession(null);