FN-5884: preserve quick chat session across reopens
Keep quick chat warm and restore the last opened conversation per project. - persist the active quick chat session id in per-project local storage and restore it before falling back to latest activity - retain in-memory quick chat state across close/reopen while resetting chat state correctly when the project changes - add storage, hook, and FAB coverage for restored sessions, warm reopen behavior, and project switching; document the updated quick chat behavior Files changed: docs/dashboard-guide.md | 3 +- packages/dashboard/app/components/QuickChatFAB.tsx | 29 ++++-- .../app/components/__tests__/QuickChatFAB.test.tsx | 112 +++++++++++++++++++-- .../__tests__/quickChatLastSessionStorage.test.ts | 55 ++++++++++ .../app/hooks/__tests__/useQuickChat.test.ts | 70 +++++++++++++ .../app/hooks/quickChatLastSessionStorage.ts | 41 ++++++++ packages/dashboard/app/hooks/useQuickChat.ts | 25 +++++ 7 files changed, 319 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-5884 Fusion-Task-Lineage: 4d689611-1824-41db-8fc2-d543407316ff
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getPersistedLastQuickChatSessionId,
|
||||
removePersistedLastQuickChatSessionId,
|
||||
setPersistedLastQuickChatSessionId,
|
||||
} from "../quickChatLastSessionStorage";
|
||||
|
||||
describe("quickChatLastSessionStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("stores and retrieves the last quick chat session id per project", () => {
|
||||
setPersistedLastQuickChatSessionId("proj-123", "session-123");
|
||||
|
||||
expect(getPersistedLastQuickChatSessionId("proj-123")).toBe("session-123");
|
||||
expect(localStorage.getItem("fusion:quick-chat-last-session:proj-123")).toBe("session-123");
|
||||
});
|
||||
|
||||
it("uses a default storage bucket when project id is missing", () => {
|
||||
setPersistedLastQuickChatSessionId(undefined, "session-default");
|
||||
|
||||
expect(getPersistedLastQuickChatSessionId()).toBe("session-default");
|
||||
expect(localStorage.getItem("fusion:quick-chat-last-session:default")).toBe("session-default");
|
||||
});
|
||||
|
||||
it("removes persisted session ids per project", () => {
|
||||
setPersistedLastQuickChatSessionId("proj-123", "session-123");
|
||||
|
||||
removePersistedLastQuickChatSessionId("proj-123");
|
||||
|
||||
expect(getPersistedLastQuickChatSessionId("proj-123")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nothing is saved", () => {
|
||||
expect(getPersistedLastQuickChatSessionId("proj-123")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows localStorage failures", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("quota exceeded");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
|
||||
expect(() => setPersistedLastQuickChatSessionId("proj-123", "session-123")).not.toThrow();
|
||||
expect(getPersistedLastQuickChatSessionId("proj-123")).toBeNull();
|
||||
expect(() => removePersistedLastQuickChatSessionId("proj-123")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ 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 { getPersistedLastQuickChatSessionId } from "../quickChatLastSessionStorage";
|
||||
import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -352,6 +353,34 @@ describe("useQuickChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the last opened session id when a session becomes active", async () => {
|
||||
const firstSession = makeSession({ id: "session-agent-1", agentId: "agent-001" });
|
||||
const secondSession = makeSession({ id: "session-agent-2", agentId: "agent-002" });
|
||||
mockFetchResumeChatSession
|
||||
.mockResolvedValueOnce({ session: firstSession })
|
||||
.mockResolvedValueOnce({ session: secondSession });
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-agent-1");
|
||||
expect(getPersistedLastQuickChatSessionId("proj-123")).toBe("session-agent-1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-002");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-agent-2");
|
||||
expect(getPersistedLastQuickChatSessionId("proj-123")).toBe("session-agent-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("switchSession with different model selections creates distinct sessions", async () => {
|
||||
const modelASession = makeSession({
|
||||
id: "session-model-a",
|
||||
@@ -408,6 +437,47 @@ describe("useQuickChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears active session and messages when the project changes", async () => {
|
||||
const session = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||
mockFetchResumeChatSession.mockResolvedValueOnce({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: "msg-1",
|
||||
sessionId: "session-existing",
|
||||
role: "assistant",
|
||||
content: "Existing project reply",
|
||||
createdAt: "2026-05-16T00:00:00.000Z",
|
||||
metadata: null,
|
||||
thinkingOutput: null,
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ projectId }) => useQuickChat(projectId), {
|
||||
initialProps: { projectId: "proj-123" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-existing");
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ id: "msg-1", content: "Existing project reply" }),
|
||||
]);
|
||||
});
|
||||
|
||||
rerender({ projectId: "proj-456" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession).toBeNull();
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("switchSession with the same target reloads messages instead of creating a new session", async () => {
|
||||
const existingSession = makeSession({
|
||||
id: "session-existing",
|
||||
|
||||
Reference in New Issue
Block a user