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
This commit is contained in:
gsxdsm
2026-06-17 01:22:21 -07:00
parent bd328f2282
commit 4e47c87c43
4 changed files with 246 additions and 5 deletions

View File

@@ -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:<projectId>`. 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.

View File

@@ -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(() => {

View File

@@ -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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
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: [
{

View File

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