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
This commit is contained in:
@@ -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] });
|
||||
|
||||
@@ -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<void>;
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user