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

@@ -32,6 +32,7 @@ import { useViewportMode } from "../hooks/useViewportMode";
import { useAppSettings } from "../hooks/useAppSettings";
import { useChatRooms } from "../hooks/useChatRooms";
import { useChatUnread } from "../hooks/useChatUnread";
import { getPersistedLastQuickChatSessionId } from "../hooks/quickChatLastSessionStorage";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
interface PendingAttachment {
@@ -1269,6 +1270,10 @@ export function QuickChatFAB({
}
const activeSessions = sessions.filter((session) => session.status !== "archived");
const persistedSessionId = getPersistedLastQuickChatSessionId(projectId);
const persistedSession = persistedSessionId
? activeSessions.find((session) => session.id === persistedSessionId) ?? null
: null;
const timestamp = (value?: string | null): number => {
if (!value) return 0;
const parsed = Date.parse(value);
@@ -1279,24 +1284,25 @@ export function QuickChatFAB({
const bLastTouched = Math.max(timestamp(b.lastMessageAt), timestamp(b.updatedAt));
return bLastTouched - aLastTouched;
})[0];
const sessionToRestore = persistedSession ?? latestSession;
if (latestSession) {
if (latestSession.modelProvider && latestSession.modelId) {
if (sessionToRestore) {
if (sessionToRestore.modelProvider && sessionToRestore.modelId) {
setChatMode("model");
setSelectedModel(`${latestSession.modelProvider}/${latestSession.modelId}`);
setSelectedModel(`${sessionToRestore.modelProvider}/${sessionToRestore.modelId}`);
} else {
setChatMode("agent");
setSelectedAgentId(latestSession.agentId);
setSelectedAgentId(sessionToRestore.agentId);
}
restoredFromExistingSessionRef.current = true;
void selectSession(latestSession);
void selectSession(sessionToRestore);
} else {
restoredFromExistingSessionRef.current = false;
}
hasAppliedInitialSessionRef.current = true;
}, [isOpen, selectSession, sessions, sessionsLoading]);
}, [isOpen, projectId, selectSession, sessions, sessionsLoading]);
// Initialize/switch quick chat session whenever the selected target changes.
// NOTE: activeSession and sessionsLoading are in the dependency array to
@@ -1305,7 +1311,6 @@ export function QuickChatFAB({
// new identity on every activeSession change.
useEffect(() => {
if (!isOpen) {
prevSessionTargetRef.current = "";
return;
}
@@ -1373,8 +1378,6 @@ export function QuickChatFAB({
return;
}
hasAppliedInitialSessionRef.current = false;
restoredFromExistingSessionRef.current = false;
setMentionPopupVisible(false);
setMentionFilter("");
setMentionStartPos(-1);
@@ -1389,6 +1392,14 @@ export function QuickChatFAB({
setPendingAttachments([]);
}, [isOpen]);
useEffect(() => {
hasAppliedInitialSessionRef.current = false;
restoredFromExistingSessionRef.current = false;
modelsRequestedRef.current = false;
modelsInitSettledRef.current = false;
prevSessionTargetRef.current = "";
}, [projectId]);
useEffect(() => {
pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]);

View File

@@ -300,7 +300,37 @@ describe("QuickChatFAB session-first UX", () => {
expect(screen.getByTestId("quick-chat-new-model-select")).toBeInTheDocument();
});
it("restores the most recently touched active session by id", async () => {
it("restores the persisted last opened active session before latest activity", async () => {
localStorage.setItem("fusion:quick-chat-last-session:proj-1", "older-updated");
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
{
...modelSession,
id: "older-updated",
updatedAt: "2026-05-13T10:00:00.000Z",
lastMessageAt: "2026-05-13T10:00:00.000Z",
},
{
...agentTwoSession,
id: "newer-last-message",
updatedAt: "2026-05-13T09:00:00.000Z",
lastMessageAt: "2026-05-13T11:00:00.000Z",
},
],
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message GPT-4o");
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("older-updated");
});
expect(mockFetchResumeChatSession).not.toHaveBeenCalled();
});
it("falls back to the latest touched session when the persisted id is stale", async () => {
localStorage.setItem("fusion:quick-chat-last-session:proj-1", "missing-session");
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
{
@@ -325,7 +355,6 @@ describe("QuickChatFAB session-first UX", () => {
expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message Agent Two");
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("newer-last-message");
});
expect(mockFetchResumeChatSession).not.toHaveBeenCalled();
});
it("skips archived newest sessions and restores the newest active session", async () => {
@@ -358,7 +387,7 @@ describe("QuickChatFAB session-first UX", () => {
expect(screen.getByTestId("quick-chat-session-option-archived-newest")).toBeInTheDocument();
});
it("reopen restores the newest active session by max(lastMessageAt, updatedAt)", async () => {
it("reopen keeps the last session the user opened", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
{
@@ -394,11 +423,11 @@ describe("QuickChatFAB session-first UX", () => {
fireEvent.click(fab);
await waitFor(() => {
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("newer-last-message");
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("older-updated");
});
});
it("reopen still skips archived newest sessions", async () => {
it("reopen keeps the last active session even when archived newer sessions exist", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
{
@@ -441,7 +470,78 @@ describe("QuickChatFAB session-first UX", () => {
fireEvent.click(fab);
await waitFor(() => {
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("active-latest");
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("active-older");
});
});
it("does not reload messages or show a loading placeholder when reopening", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{
id: "msg-1",
sessionId: "session-model",
role: "assistant",
content: "Warm conversation",
createdAt: "2026-05-16T00:00:03.000Z",
metadata: null,
thinkingOutput: null,
},
],
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
const fab = screen.getByTestId("quick-chat-fab");
fireEvent.click(fab);
await waitFor(() => {
expect(screen.getByText("Warm conversation")).toBeInTheDocument();
});
const messageFetchCountAfterInitialOpen = mockFetchChatMessages.mock.calls.length;
fireEvent.click(screen.getByTestId("quick-chat-close"));
fireEvent.click(fab);
await waitFor(() => {
expect(screen.getByText("Warm conversation")).toBeInTheDocument();
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("session-model");
});
expect(mockFetchChatMessages).toHaveBeenCalledTimes(messageFetchCountAfterInitialOpen);
expect(screen.queryByText("Loading conversation…")).not.toBeInTheDocument();
});
it("re-restores for a new project without leaking the previous project session", async () => {
mockFetchChatSessions.mockImplementation(async (projectId?: string) => ({
sessions: projectId === "proj-2"
? [
{
...agentTwoSession,
id: "proj-2-session",
updatedAt: "2026-05-17T10:00:00.000Z",
lastMessageAt: "2026-05-17T10:00:00.000Z",
},
]
: [
{
...modelSession,
id: "proj-1-session",
updatedAt: "2026-05-16T10:00:00.000Z",
lastMessageAt: "2026-05-16T10:00:00.000Z",
},
],
}));
const { rerender } = render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("proj-1-session");
});
rerender(<QuickChatFAB addToast={vi.fn()} projectId="proj-2" />);
await waitFor(() => {
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("proj-2-session");
expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message Agent Two");
});
});

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

View File

@@ -0,0 +1,41 @@
const QUICK_CHAT_LAST_SESSION_STORAGE_PREFIX = "fusion:quick-chat-last-session:";
function getQuickChatLastSessionStorageKey(projectId?: string | null): string {
return `${QUICK_CHAT_LAST_SESSION_STORAGE_PREFIX}${projectId || "default"}`;
}
export function getPersistedLastQuickChatSessionId(projectId?: string | null): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return localStorage.getItem(getQuickChatLastSessionStorageKey(projectId));
} catch {
return null;
}
}
export function setPersistedLastQuickChatSessionId(projectId: string | null | undefined, sessionId: string): void {
if (typeof window === "undefined") {
return;
}
try {
localStorage.setItem(getQuickChatLastSessionStorageKey(projectId), sessionId);
} catch {
// Ignore localStorage failures so quick-chat session selection still works in-memory.
}
}
export function removePersistedLastQuickChatSessionId(projectId?: string | null): void {
if (typeof window === "undefined") {
return;
}
try {
localStorage.removeItem(getQuickChatLastSessionStorageKey(projectId));
} catch {
// Ignore localStorage failures so cleanup paths do not throw.
}
}

View File

@@ -26,6 +26,7 @@ import {
removePersistedPendingChatMessage,
setPersistedPendingChatMessage,
} from "./chatPendingMessageStorage";
import { setPersistedLastQuickChatSessionId } from "./quickChatLastSessionStorage";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
interface ModelSelection {
@@ -251,6 +252,14 @@ export function useQuickChat(
lastAttachedGenerationRef.current = null;
}, [projectId]);
useEffect(() => {
if (!activeSession?.id) {
return;
}
setPersistedLastQuickChatSessionId(projectId, activeSession.id);
}, [activeSession?.id, projectId]);
const refreshSessions = useCallback(async () => {
setSessionsLoading(true);
try {
@@ -532,6 +541,22 @@ export function useQuickChat(
setIsStreaming(false);
}, []);
useEffect(() => {
if (streamRef.current) {
streamRef.current.close();
streamRef.current = null;
}
lastAttachedGenerationRef.current = null;
currentSessionKeyRef.current = "";
currentSessionTargetRef.current = null;
activeSessionRef.current = null;
setActiveSession(null);
setMessages([]);
setSessions([]);
resetTransientComposerState();
}, [projectId, resetTransientComposerState]);
// Switch to a different chat target session
const switchSession = useCallback(
async (agentId: string, modelProvider?: string, modelId?: string) => {