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:
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 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",
|
||||||
|
|||||||
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,
|
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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user