From 4e47c87c43fe5767a270b1cad5037ed2ba3c8a05 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 01:22:21 -0700 Subject: [PATCH] FN-6510: preserve last opened quick chat session Quick Chat now restores the persisted session id without same-target auto-init replacing it. - Wait for persisted session lookup before auto-initializing the selected quick chat target. - Skip the first same-target switch after restoring an existing session so the restored id remains authoritative. - Prefer message activity over metadata-only updates when falling back from stale persisted sessions. - Cover model, agent, and mobile restoration paths plus hook-level same-target replay behavior. - Document the quick chat last-session restoration regression and invariant. Files changed: docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md | 60 ++++++++++ packages/dashboard/app/components/QuickChatFAB.tsx | 23 +++- packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx | 130 ++++++++++++++++++++- packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts | 38 ++++++ 4 files changed, 246 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-6510 Fusion-Task-Lineage: 135807dd-30f3-4032-9de8-f34927e5c44d --- .../quick-chat-last-opened-session-restore.md | 60 ++++++++ .../dashboard/app/components/QuickChatFAB.tsx | 23 +++- .../__tests__/QuickChatFAB.test.tsx | 130 +++++++++++++++++- .../app/hooks/__tests__/useQuickChat.test.ts | 38 +++++ 4 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md diff --git a/docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md b/docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md new file mode 100644 index 0000000000..d595a2f3c6 --- /dev/null +++ b/docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md @@ -0,0 +1,60 @@ +--- +title: "Quick Chat last-opened session restore" +date: 2026-06-17 +category: ui-bugs +module: packages/dashboard/app/components/QuickChatFAB +problem_type: ui_bug +component: frontend_quick_chat +applies_when: "Quick Chat restores direct chat sessions after reloads, project switches, or a cold FAB open while session fetching is still in flight." +symptoms: + - "Opening Quick Chat restores an older or seemingly random direct thread" + - "The wrong thread often shares the same agent or model target as the intended last-opened session" + - "The persisted last-session localStorage key is overwritten before the real session list restore can run" +root_cause: automatic_same_target_resolution_raced_persisted_id_restore +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/components/QuickChatFAB.tsx + - packages/dashboard/app/hooks/useQuickChat.ts + - packages/dashboard/app/hooks/quickChatLastSessionStorage.ts + - FN-3972 + - FN-4235 + - FN-4430 + - FN-6510 +tags: + - quick-chat + - session-restore + - localstorage + - same-target-collision + - regression-test +--- + +# Quick Chat last-opened session restore + +## Problem + +Quick Chat stores the last opened direct session in `fusion:quick-chat-last-session:`. A cold open can request sessions and models at the same time. If automatic target initialization (`switchSession` / `startModelChat`) runs before the session list returns, it can resolve a same-target session from the server, set it active, and trigger the hook's active-session persistence effect. That overwrites the persisted id before the restore effect can find the user's exact last-opened session. + +This failure is easy to miss when tests only use different targets. The important repro has two active sessions sharing the same agent or model target, with the persisted session not being the newest/touched one for that target. + +## Solution + +Treat the persisted id as the source of truth until the initial direct-session restore has either used it or proven it stale. + +- While a persisted last-session id exists and the initial session fetch is still loading, do not run automatic target initialization. +- When a session is restored from the list, skip the first automatic same-target switch. Restore is id-specific; same target is not equivalent. +- For stale or missing persisted ids, rank fallback sessions by `lastMessageAt` before `updatedAt` so metadata-only updates do not displace the latest real conversation. +- Keep chat rooms separate from direct-session restore; room active state should not feed the last direct-session key. + +## Regression coverage + +Use DOM tests around `QuickChatFAB` for the real symptom because the race spans component restore effects, model/agent target selection, and the `useQuickChat` persistence effect. + +Cover: + +- Agent-backed and model-backed same-target collisions. +- Delayed session fetches where auto-init would previously clobber `localStorage`. +- Valid, stale/missing, and archived persisted ids. +- Empty/single/multiple session lists. +- Fresh render, warm close/reopen, project switch, desktop FAB, and mobile FAB paths. +- Hook-level same-target replay (`selectSession` followed by `switchSession` for the same target) so the active id and persisted id remain the selected session. diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index b47b163663..ff63c2b538 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -1458,9 +1458,14 @@ export function QuickChatFAB({ const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; }; + /* + FNXC:QuickChatRestore 2026-06-17-00:17: + Quick Chat must resume the exact direct session the user last opened; only stale or missing persisted ids may fall back. + Rank fallback sessions by conversation activity first because metadata-only updatedAt bumps can make an older same-target thread look newer than the user's last real chat. + */ const latestSession = [...activeSessions].sort((a, b) => { - const aLastTouched = Math.max(timestamp(a.lastMessageAt), timestamp(a.updatedAt)); - const bLastTouched = Math.max(timestamp(b.lastMessageAt), timestamp(b.updatedAt)); + const aLastTouched = timestamp(a.lastMessageAt) || timestamp(a.updatedAt); + const bLastTouched = timestamp(b.lastMessageAt) || timestamp(b.updatedAt); return bLastTouched - aLastTouched; })[0]; const sessionToRestore = persistedSession ?? latestSession; @@ -1501,6 +1506,14 @@ export function QuickChatFAB({ return; } + const persistedSessionId = getPersistedLastQuickChatSessionId(projectId); + const waitingForPersistedSessionRestore = !hasAppliedInitialSessionRef.current + && Boolean(persistedSessionId) + && sessionsLoading; + if (waitingForPersistedSessionRestore) { + return; + } + if (!sessionTargetKey) { prevSessionTargetRef.current = ""; return; @@ -1520,6 +1533,11 @@ export function QuickChatFAB({ && !sessionsLoading; if (restoredFromExistingSessionRef.current) { + /* + FNXC:QuickChatRestore 2026-06-17-00:18: + A restored direct session is id-specific, not just target-specific. + Skip the first automatic same-target switch so fetchResumeChatSession cannot replace the restored session with a different thread that shares the agent or model target and then clobber localStorage. + */ restoredFromExistingSessionRef.current = false; prevSessionTargetRef.current = sessionTargetKey; return; @@ -1550,6 +1568,7 @@ export function QuickChatFAB({ startModelChat, switchSession, skipNextSessionInitRef, + projectId, ]); useEffect(() => { diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 83d1332976..ae36508da6 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -12,6 +12,7 @@ import { useChatRooms } from "../../hooks/useChatRooms"; import * as mobileScrollLock from "../../hooks/useMobileScrollLock"; import { QuickChatFAB } from "../QuickChatFAB"; import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { getPersistedLastQuickChatSessionId } from "../../hooks/quickChatLastSessionStorage"; vi.mock("../../api", () => ({ fetchResumeChatSession: vi.fn(), @@ -434,6 +435,128 @@ describe("QuickChatFAB session-first UX", () => { expect(screen.getByTestId("quick-chat-new-model-select")).toBeInTheDocument(); }); + it("keeps a persisted model session through same-target auto-init before sessions load", async () => { + localStorage.setItem("fusion:quick-chat-last-session:proj-1", "model-last-opened"); + const sessionsDeferred = createDeferredPromise<{ sessions: ChatSession[] }>(); + mockFetchChatSessions.mockReturnValueOnce(sessionsDeferred.promise); + mockFetchResumeChatSession.mockResolvedValue({ + session: { + ...modelSession, + id: "model-auto-resolved", + updatedAt: "2026-05-13T12:00:00.000Z", + lastMessageAt: "2026-05-13T12:00:00.000Z", + }, + }); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + await waitFor(() => { + expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-1"); + }); + expect(mockFetchResumeChatSession).not.toHaveBeenCalled(); + expect(getPersistedLastQuickChatSessionId("proj-1")).toBe("model-last-opened"); + + sessionsDeferred.resolve({ + sessions: [ + { + ...modelSession, + id: "model-last-opened", + updatedAt: "2026-05-13T10:00:00.000Z", + lastMessageAt: "2026-05-13T10:00:00.000Z", + }, + { + ...modelSession, + id: "model-auto-resolved", + updatedAt: "2026-05-13T12:00:00.000Z", + lastMessageAt: "2026-05-13T12:00:00.000Z", + }, + ], + }); + + await waitFor(() => { + expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("model-last-opened"); + expect(getPersistedLastQuickChatSessionId("proj-1")).toBe("model-last-opened"); + }); + }); + + it("restores the persisted session from the mobile FAB path", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + localStorage.setItem("fusion:quick-chat-last-session:proj-1", "mobile-last-opened"); + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [ + { + ...modelSession, + id: "mobile-last-opened", + updatedAt: "2026-05-13T10:00:00.000Z", + lastMessageAt: "2026-05-13T10:00:00.000Z", + }, + { + ...modelSession, + id: "mobile-newer-same-target", + updatedAt: "2026-05-13T12:00:00.000Z", + lastMessageAt: "2026-05-13T12:00:00.000Z", + }, + ], + }); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + await waitFor(() => { + expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("mobile-last-opened"); + expect(getPersistedLastQuickChatSessionId("proj-1")).toBe("mobile-last-opened"); + }); + }); + + it("keeps a persisted agent session through same-target auto-init before sessions load", async () => { + localStorage.setItem("fusion:quick-chat-last-session:proj-1", "agent-last-opened"); + const sessionsDeferred = createDeferredPromise<{ sessions: ChatSession[] }>(); + mockFetchChatSessions.mockReturnValueOnce(sessionsDeferred.promise); + mockFetchResumeChatSession.mockResolvedValue({ + session: { + ...agentSession, + id: "agent-auto-resolved", + updatedAt: "2026-05-13T12:00:00.000Z", + lastMessageAt: "2026-05-13T12:00:00.000Z", + }, + }); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + await waitFor(() => { + expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-1"); + }); + expect(mockFetchResumeChatSession).not.toHaveBeenCalled(); + expect(getPersistedLastQuickChatSessionId("proj-1")).toBe("agent-last-opened"); + + sessionsDeferred.resolve({ + sessions: [ + { + ...agentSession, + id: "agent-last-opened", + updatedAt: "2026-05-13T10:00:00.000Z", + lastMessageAt: "2026-05-13T10:00:00.000Z", + }, + { + ...agentSession, + id: "agent-auto-resolved", + updatedAt: "2026-05-13T12:00:00.000Z", + lastMessageAt: "2026-05-13T12:00:00.000Z", + }, + ], + }); + + await waitFor(() => { + expect(screen.getByTestId("quick-chat-session-dropdown")).toHaveValue("agent-last-opened"); + expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message Agent One"); + expect(getPersistedLastQuickChatSessionId("proj-1")).toBe("agent-last-opened"); + }); + }); + 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({ @@ -463,14 +586,14 @@ describe("QuickChatFAB session-first UX", () => { expect(mockFetchResumeChatSession).not.toHaveBeenCalled(); }); - it("falls back to the latest touched session when the persisted id is stale", async () => { + it("falls back to the latest conversation session when the persisted id is stale", async () => { localStorage.setItem("fusion:quick-chat-last-session:proj-1", "missing-session"); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [ { ...modelSession, id: "older-updated", - updatedAt: "2026-05-13T10:00:00.000Z", + updatedAt: "2026-05-13T12:00:00.000Z", lastMessageAt: "2026-05-13T10:00:00.000Z", }, { @@ -491,7 +614,8 @@ describe("QuickChatFAB session-first UX", () => { }); }); - it("skips archived newest sessions and restores the newest active session", async () => { + it("skips archived persisted sessions and restores the newest active session", async () => { + localStorage.setItem("fusion:quick-chat-last-session:proj-1", "archived-newest"); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [ { diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index 1904a5be33..47fec01ce6 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -593,6 +593,44 @@ describe("useQuickChat", () => { }); }); + it("does not clobber a selected same-target session id when automatic init replays the target", async () => { + const lastOpenedSession = makeSession({ + id: "model-last-opened", + agentId: FN_AGENT_ID, + modelProvider: "openai", + modelId: "gpt-4o", + }); + const autoResolvedSession = makeSession({ + id: "model-auto-resolved", + agentId: FN_AGENT_ID, + modelProvider: "openai", + modelId: "gpt-4o", + }); + localStorage.setItem("fusion:quick-chat-last-session:proj-123", lastOpenedSession.id); + mockFetchResumeChatSession.mockResolvedValue({ session: autoResolvedSession }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.selectSession(lastOpenedSession); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe(lastOpenedSession.id); + expect(getPersistedLastQuickChatSessionId("proj-123")).toBe(lastOpenedSession.id); + }); + + await act(async () => { + await result.current.switchSession(FN_AGENT_ID, "openai", "gpt-4o"); + }); + + await waitFor(() => { + expect(result.current.activeSession?.id).toBe(lastOpenedSession.id); + expect(getPersistedLastQuickChatSessionId("proj-123")).toBe(lastOpenedSession.id); + }); + expect(mockFetchResumeChatSession).not.toHaveBeenCalled(); + }); + it("switchSession with different model selections creates distinct sessions", async () => { const modelASession = makeSession({ id: "session-model-a",