FN-5852: persist queued chat messages across navigation

Keep queued follow-up chat drafts across session switches, reloads, and view re-entry.

- persist queued pending-message text per chat session in shared localStorage helpers
- restore and flush queued follow-up messages in full Chat when returning to an active session
- restore and flush queued follow-up messages in Quick Chat and cover the recovery path with tests
- document queued-message persistence behavior in the dashboard guide

Files changed:
 docs/dashboard-guide.md                            |   2 +
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 119 +++++++++++++++++-
 .../app/hooks/__tests__/useQuickChat.test.ts       | 135 +++++++++++++++++++++
 .../app/hooks/chatPendingMessageStorage.ts         |  48 ++++++++
 packages/dashboard/app/hooks/useChat.ts            |  38 ++++++
 packages/dashboard/app/hooks/useQuickChat.ts       |  35 ++++++
 6 files changed, 376 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-5852

Fusion-Task-Lineage: 1cddfb19-7e02-40e5-b373-5d593dfcfe2a
This commit is contained in:
gsxdsm
2026-06-01 22:11:30 -07:00
parent a21038de6d
commit 70e7a6b30a
6 changed files with 376 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChat } from "../useChat";
import * as apiModule from "../../api";
import { getChatPendingMessageKey } from "../chatPendingMessageStorage";
import * as swrCacheModule from "../../utils/swrCache";
import type { ChatSession, ChatMessage } from "@fusion/core";
@@ -1625,6 +1626,95 @@ describe("useChat", () => {
expect(addToast).not.toHaveBeenCalledWith("Still waiting for previous response — message queued", "warning");
});
it("persists queued message text to localStorage while streaming", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
mockStreamChatResponse.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.activeSession?.id).toBe("session-001");
});
act(() => {
result.current.sendMessage("First");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
result.current.sendMessage("Queued follow-up");
});
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
});
it("rehydrates queued message from localStorage after remount", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
inFlightGeneration: {
streamingText: "partial",
streamingThinking: "",
toolCalls: [],
},
};
mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const firstHook = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(firstHook.result.current.sessions).toHaveLength(1);
});
act(() => {
firstHook.result.current.selectSession("session-001");
});
await waitFor(() => {
expect(firstHook.result.current.isStreaming).toBe(true);
});
act(() => {
firstHook.result.current.sendMessage("Queued follow-up");
});
await waitFor(() => {
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBe("Queued follow-up");
});
firstHook.unmount();
const secondHook = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(secondHook.result.current.sessions).toHaveLength(1);
});
act(() => {
secondHook.result.current.selectSession("session-001");
});
await waitFor(() => {
expect(secondHook.result.current.pendingMessage).toBe("Queued follow-up");
});
});
describe("queued message closure behavior", () => {
it("queued message auto-sends after onDone with the active session and completes second stream", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
@@ -1985,7 +2075,7 @@ describe("useChat", () => {
});
});
it("clearPendingMessage clears pending message", async () => {
it("clearPendingMessage clears pending message and removes persisted queue entry", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
@@ -2027,6 +2117,31 @@ describe("useChat", () => {
});
expect(result.current.pendingMessage).toBe("");
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] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
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");
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
});
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
});
it("stopStreaming flushes pendingMessage", async () => {
@@ -2068,6 +2183,8 @@ describe("useChat", () => {
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
});
expect(localStorage.getItem(getChatPendingMessageKey("session-001"))).toBeNull();
});
it("loads more messages with pagination", async () => {

View File

@@ -2,6 +2,7 @@ import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSession } from "@fusion/core";
import * as apiModule from "../../api";
import { getChatPendingMessageKey } from "../chatPendingMessageStorage";
import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
vi.mock("../../api", () => ({
@@ -49,6 +50,7 @@ const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
describe("useQuickChat", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchResumeChatSession.mockResolvedValue({ session: null });
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockCreateChatSession.mockResolvedValue({
@@ -616,6 +618,8 @@ describe("useQuickChat", () => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.activeSession?.id).toBe("session-fresh");
});
expect(localStorage.getItem(getChatPendingMessageKey("session-existing"))).toBeNull();
});
it("stopStreaming aborts stream and resets streaming state", async () => {
@@ -687,6 +691,28 @@ describe("useQuickChat", () => {
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
expect(result.current.pendingMessage).toBe("");
});
expect(localStorage.getItem(getChatPendingMessageKey("session-existing"))).toBeNull();
});
it("restored quick-chat queued message auto-sends once after generation already completed", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
localStorage.setItem(getChatPendingMessageKey("session-existing")!, "Queued follow-up");
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse.mock.calls[0]?.[1]).toBe("Queued follow-up");
});
expect(localStorage.getItem(getChatPendingMessageKey("session-existing"))).toBeNull();
});
it("stopStreaming with no pendingMessage cancels stream without sending anything", async () => {
@@ -715,6 +741,43 @@ describe("useQuickChat", () => {
});
});
it("clearPendingMessage removes persisted quick-chat queue entry", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
void result.current.sendMessage("Hello");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
void result.current.sendMessage("Queued follow-up");
});
await waitFor(() => {
expect(result.current.pendingMessage).toBe("Queued follow-up");
});
act(() => {
result.current.clearPendingMessage();
});
expect(result.current.pendingMessage).toBe("");
expect(localStorage.getItem(getChatPendingMessageKey("session-existing"))).toBeNull();
});
it("sending during streaming queues message without warning toast", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
@@ -795,6 +858,78 @@ describe("useQuickChat", () => {
});
});
it("persists queued quick-chat message text to localStorage while streaming", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
void result.current.sendMessage("Hello");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
act(() => {
void result.current.sendMessage("Queued follow-up");
});
expect(localStorage.getItem(getChatPendingMessageKey("session-existing"))).toBe("Queued follow-up");
});
it("rehydrates queued quick-chat message from localStorage after remount", async () => {
const existingSession = {
...makeSession({ id: "session-existing", agentId: "agent-001" }),
isGenerating: true,
inFlightGeneration: {
streamingText: "partial",
streamingThinking: "",
toolCalls: [],
},
};
mockFetchResumeChatSession.mockResolvedValue({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const firstHook = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await firstHook.result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(firstHook.result.current.isStreaming).toBe(true);
});
act(() => {
void firstHook.result.current.sendMessage("Queued follow-up");
});
await waitFor(() => {
expect(localStorage.getItem(getChatPendingMessageKey("session-existing"))).toBe("Queued follow-up");
});
firstHook.unmount();
const secondHook = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await secondHook.result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(secondHook.result.current.pendingMessage).toBe("Queued follow-up");
});
});
describe("message queue behavior", () => {
it("queued message is auto-sent after streaming onDone", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });

View File

@@ -0,0 +1,48 @@
const CHAT_PENDING_MESSAGE_STORAGE_PREFIX = "fusion:chat-pending:";
export function getChatPendingMessageKey(sessionId: string | null | undefined): string | null {
if (!sessionId) {
return null;
}
return `${CHAT_PENDING_MESSAGE_STORAGE_PREFIX}${sessionId}`;
}
export function getPersistedPendingChatMessage(sessionId: string | null | undefined): string {
const key = getChatPendingMessageKey(sessionId);
if (!key || typeof window === "undefined") {
return "";
}
try {
return localStorage.getItem(key) ?? "";
} catch {
return "";
}
}
export function setPersistedPendingChatMessage(sessionId: string | null | undefined, content: string): void {
const key = getChatPendingMessageKey(sessionId);
if (!key || typeof window === "undefined") {
return;
}
try {
localStorage.setItem(key, content);
} catch {
// Ignore localStorage failures so chat queuing still works in-memory.
}
}
export function removePersistedPendingChatMessage(sessionId: string | null | undefined): void {
const key = getChatPendingMessageKey(sessionId);
if (!key || typeof window === "undefined") {
return;
}
try {
localStorage.removeItem(key);
} catch {
// Ignore localStorage failures so cleanup paths do not throw.
}
}

View File

@@ -39,6 +39,11 @@ export interface ChatSessionInfo {
export type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import { createChatStreamHandlers } from "./createChatStreamHandlers";
import {
getPersistedPendingChatMessage,
removePersistedPendingChatMessage,
setPersistedPendingChatMessage,
} from "./chatPendingMessageStorage";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { useAgentsMapCache } from "./useAgentsMapCache";
@@ -482,6 +487,7 @@ export function useChat(
const resetTransientComposerState = useCallback(() => {
cancelStreamingFlushesRef.current?.();
cancelStreamingFlushesRef.current = null;
removePersistedPendingChatMessage(activeSessionRef.current?.id);
pendingMessageRef.current = "";
setPendingMessage("");
setStreamingText("");
@@ -491,6 +497,7 @@ export function useChat(
}, []);
const clearPendingMessage = useCallback(() => {
removePersistedPendingChatMessage(activeSessionRef.current?.id);
pendingMessageRef.current = "";
setPendingMessage("");
}, []);
@@ -501,6 +508,7 @@ export function useChat(
return;
}
removePersistedPendingChatMessage(activeSessionRef.current?.id);
pendingMessageRef.current = "";
setPendingMessage("");
sendMessageRef.current(queuedMessage);
@@ -596,6 +604,9 @@ export function useChat(
if (id && currentActiveSessionId === id && !sessionOverride) {
return;
}
if (currentActiveSessionId && currentActiveSessionId !== id) {
removePersistedPendingChatMessage(currentActiveSessionId);
}
// Close any existing stream
if (streamRef.current) {
@@ -663,6 +674,32 @@ export function useChat(
// This is needed to avoid circular dependencies in useEffect
selectSessionRef.current = selectSession;
useEffect(() => {
const sessionId = activeSession?.id;
if (!sessionId) {
return;
}
const restoredPendingMessage = getPersistedPendingChatMessage(sessionId);
if (!restoredPendingMessage) {
return;
}
pendingMessageRef.current = restoredPendingMessage;
setPendingMessage(restoredPendingMessage);
queueMicrotask(() => {
if (
activeSessionRef.current?.id === sessionId &&
pendingMessageRef.current.trim().length > 0 &&
!isStreamingRef.current &&
!streamRef.current
) {
flushPendingMessage();
}
});
}, [activeSession?.id, flushPendingMessage]);
// Create a new session
const createSession = useCallback(
async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => {
@@ -829,6 +866,7 @@ export function useChat(
if (isStreamingRef.current) {
pendingMessageRef.current = content;
setPendingMessage(content);
setPersistedPendingChatMessage(activeSession.id, content);
return;
}

View File

@@ -21,6 +21,11 @@ export const FN_AGENT_ID = "__fn_agent__";
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import { createChatStreamHandlers } from "./createChatStreamHandlers";
import {
getPersistedPendingChatMessage,
removePersistedPendingChatMessage,
setPersistedPendingChatMessage,
} from "./chatPendingMessageStorage";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
interface ModelSelection {
@@ -276,6 +281,7 @@ export function useQuickChat(
);
const clearPendingMessage = useCallback(() => {
removePersistedPendingChatMessage(activeSessionRef.current?.id);
pendingMessageRef.current = "";
setPendingMessage("");
}, []);
@@ -286,6 +292,7 @@ export function useQuickChat(
return;
}
removePersistedPendingChatMessage(activeSessionRef.current?.id);
pendingMessageRef.current = "";
setPendingMessage("");
const queuedCompletion = queuedPreSessionCompletionRef.current;
@@ -514,6 +521,7 @@ export function useQuickChat(
const resetTransientComposerState = useCallback(() => {
cancelStreamingFlushesRef.current?.();
cancelStreamingFlushesRef.current = null;
removePersistedPendingChatMessage(activeSessionRef.current?.id);
pendingMessageRef.current = "";
setPendingMessage("");
queuedPreSessionCompletionRef.current?.resolve();
@@ -608,6 +616,32 @@ export function useQuickChat(
}
}, [attachIfGenerating, projectId, resetTransientComposerState]);
useEffect(() => {
const sessionId = activeSession?.id;
if (!sessionId) {
return;
}
const restoredPendingMessage = getPersistedPendingChatMessage(sessionId);
if (!restoredPendingMessage) {
return;
}
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]);
const startModelChat = useCallback(
async (modelProvider: string, modelId: string) => {
await switchSession(FN_AGENT_ID, modelProvider, modelId);
@@ -765,6 +799,7 @@ export function useQuickChat(
pendingMessageRef.current = content;
setPendingMessage(content);
setPersistedPendingChatMessage(activeSession.id, content);
return Promise.resolve();
}