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:
gsxdsm
2026-06-02 09:34:19 -07:00
parent cf3b9a575e
commit 9e00a72d7d
7 changed files with 319 additions and 16 deletions

View File

@@ -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();
});
});

View File

@@ -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",