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

@@ -189,7 +189,8 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
- Entering `/new` or `/clear` (exact match after trimming) in the Quick Chat composer clears the active thread target: direct/model targets use `startFreshSession(...)`, while room targets call `rooms.clearRoom(activeRoom.id)`. - Entering `/new` or `/clear` (exact match after trimming) in the Quick Chat composer clears the active thread target: direct/model targets use `startFreshSession(...)`, while room targets call `rooms.clearRoom(activeRoom.id)`.
- The `+` action opens an inline new-session chooser (inside the panel, not a modal) with `Model` selected by default and optional switch to `Agent` - The `+` action opens an inline new-session chooser (inside the panel, not a modal) with `Model` selected by default and optional switch to `Agent`
- Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list - Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list
- On every open, Quick Chat restores the most recently used non-archived session by latest activity (`max(lastMessageAt, updatedAt)`); only when no prior session exists does it fall back to the first agent / configured default model. - On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists.
- Closing and reopening Quick Chat keeps the active conversation warm in memory, so messages stay visible without a conversation reload or "Loading conversation…" flash.
- Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued text and flushes it once the active response completes. - Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued text and flushes it once the active response completes.
- Resume lookups still use targeted session queries instead of loading the full active-session list first - Resume lookups still use targeted session queries instead of loading the full active-session list first
- Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping - Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping

View File

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

View File

@@ -300,7 +300,37 @@ describe("QuickChatFAB session-first UX", () => {
expect(screen.getByTestId("quick-chat-new-model-select")).toBeInTheDocument(); 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({ mockFetchChatSessions.mockResolvedValueOnce({
sessions: [ 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-input")).toHaveAttribute("placeholder", "Message Agent Two");
expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("newer-last-message"); 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 () => { 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(); 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({ mockFetchChatSessions.mockResolvedValueOnce({
sessions: [ sessions: [
{ {
@@ -394,11 +423,11 @@ describe("QuickChatFAB session-first UX", () => {
fireEvent.click(fab); fireEvent.click(fab);
await waitFor(() => { 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({ mockFetchChatSessions.mockResolvedValueOnce({
sessions: [ sessions: [
{ {
@@ -441,7 +470,78 @@ describe("QuickChatFAB session-first UX", () => {
fireEvent.click(fab); fireEvent.click(fab);
await waitFor(() => { 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 type { ChatSession } from "@fusion/core";
import * as apiModule from "../../api"; import * as apiModule from "../../api";
import { getChatPendingMessageKey } from "../chatPendingMessageStorage"; import { getChatPendingMessageKey } from "../chatPendingMessageStorage";
import { getPersistedLastQuickChatSessionId } from "../quickChatLastSessionStorage";
import { FN_AGENT_ID, useQuickChat } from "../useQuickChat"; import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
vi.mock("../../api", () => ({ 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 () => { it("switchSession with different model selections creates distinct sessions", async () => {
const modelASession = makeSession({ const modelASession = makeSession({
id: "session-model-a", 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 () => { it("switchSession with the same target reloads messages instead of creating a new session", async () => {
const existingSession = makeSession({ const existingSession = makeSession({
id: "session-existing", 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, removePersistedPendingChatMessage,
setPersistedPendingChatMessage, setPersistedPendingChatMessage,
} from "./chatPendingMessageStorage"; } from "./chatPendingMessageStorage";
import { setPersistedLastQuickChatSessionId } from "./quickChatLastSessionStorage";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension"; import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
interface ModelSelection { interface ModelSelection {
@@ -251,6 +252,14 @@ export function useQuickChat(
lastAttachedGenerationRef.current = null; lastAttachedGenerationRef.current = null;
}, [projectId]); }, [projectId]);
useEffect(() => {
if (!activeSession?.id) {
return;
}
setPersistedLastQuickChatSessionId(projectId, activeSession.id);
}, [activeSession?.id, projectId]);
const refreshSessions = useCallback(async () => { const refreshSessions = useCallback(async () => {
setSessionsLoading(true); setSessionsLoading(true);
try { try {
@@ -532,6 +541,22 @@ export function useQuickChat(
setIsStreaming(false); 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 // Switch to a different chat target session
const switchSession = useCallback( const switchSession = useCallback(
async (agentId: string, modelProvider?: string, modelId?: string) => { async (agentId: string, modelProvider?: string, modelId?: string) => {