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:
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
41
packages/dashboard/app/hooks/quickChatLastSessionStorage.ts
Normal file
41
packages/dashboard/app/hooks/quickChatLastSessionStorage.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user