diff --git a/.changeset/fn-8504-chat-reentry-state.md b/.changeset/fn-8504-chat-reentry-state.md new file mode 100644 index 0000000000..caed9554bd --- /dev/null +++ b/.changeset/fn-8504-chat-reentry-state.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore active chat thinking and partial response state when returning to a conversation. +category: fix +dev: Guards direct-chat re-entry refreshes and late stream terminal callbacks by session selection ownership. diff --git a/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx index 8102e5ad9d..52b33240c2 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx @@ -194,6 +194,53 @@ describe("FN-6599 ChatView streaming prior thread", () => { expect(screen.getByText("Second answer")).toBeInTheDocument(); }); + it.each([ + ["desktop", 1280], + ["mobile", 390], + ])("FN-8504 restores the authoritative in-flight bubble after leaving and re-entering on %s", async (_label, width) => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + window.dispatchEvent(new Event("resize")); + const generatingSession = makeSession({ + id: "session-reentry", + agentId: "agent-001", + title: "Re-entry", + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "authoritative partial response", + streamingThinking: "authoritative reasoning", + toolCalls: [{ toolName: "read", status: "running" as const, isError: false, args: { path: "README.md" } }], + replayFromEventId: 23, + updatedAt: "2026-07-20T19:15:00.000Z", + }, + }); + const priorMessage = makeMessage({ id: "msg-prior", sessionId: generatingSession.id, role: "user", content: "Prior question" }); + mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined); + mockFetchChatSessions.mockResolvedValue({ sessions: [generatingSession] }); + mockFetchChatSession.mockResolvedValue({ session: generatingSession }); + mockFetchChatMessages.mockResolvedValue({ messages: [priorMessage] }); + + const firstView = render(); + await screen.findByText("authoritative partial response"); + firstView.unmount(); + + render(); + await waitFor(() => { + expect(screen.getByText("authoritative partial response")).toBeInTheDocument(); + expect(screen.getByText("Thinking")).toBeInTheDocument(); + expect(screen.getByText("read")).toBeInTheDocument(); + expect(screen.getByText("running")).toBeInTheDocument(); + expect(screen.getByText("Prior question")).toBeInTheDocument(); + expect(mockAttachChatStream).toHaveBeenLastCalledWith( + generatingSession.id, + expect.any(Object), + "proj-123", + { lastEventId: 23 }, + ); + }); + expect(mockAttachChatStream).toHaveBeenCalledTimes(2); + }); + it.each([ ["desktop", 1280], ["mobile", 390], diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index c5ff4f6251..b2b30708d1 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -1927,7 +1927,7 @@ describe("useChat", () => { }); }); - it("FN-5104 does not reattach after stopStreaming cancels active generation", async () => { + it("FN-5104 does not reattach after an authoritative idle response", async () => { const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true, @@ -1954,17 +1954,17 @@ describe("useChat", () => { }); await waitFor(() => { - expect(mockAttachChatStream).toHaveBeenCalledTimes(1); + // A stale list row must not reopen a generation that the authoritative endpoint says ended. + expect(result.current.isStreaming).toBe(false); + expect(mockAttachChatStream).not.toHaveBeenCalled(); }); act(() => { result.current.stopStreaming(); }); - await waitFor(() => { - expect(mockAttachChatStream).toHaveBeenCalledTimes(1); - expect(result.current.isStreaming).toBe(false); - }); + expect(mockAttachChatStream).not.toHaveBeenCalled(); + expect(result.current.isStreaming).toBe(false); }); it("FN-7656 reattaches and shows working state on refresh reporting isGenerating with no inFlightGeneration snapshot yet (pre-first-delta)", async () => { @@ -2080,6 +2080,145 @@ describe("useChat", () => { expect(result.current.activeSession?.id).toBe("session-002"); }); + it("FN-8504 waits for the authoritative re-entry snapshot before attaching from its cursor", async () => { + const staleListSession = { + ...makeSession({ id: "session-stale-cursor", agentId: "agent-001" }), + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "stale partial", + streamingThinking: "stale reasoning", + toolCalls: [], + replayFromEventId: 17, + updatedAt: "2026-07-20T19:00:00.000Z", + }, + }; + const authoritativeRefresh = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleListSession] }); + mockFetchChatSession.mockReturnValueOnce(authoritativeRefresh.promise); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat()); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => { + result.current.selectSession(staleListSession.id); + }); + + await waitFor(() => { + expect(mockFetchChatSession).toHaveBeenCalledWith(staleListSession.id, undefined); + }); + expect(mockAttachChatStream).not.toHaveBeenCalled(); + + await act(async () => { + authoritativeRefresh.resolve({ + session: { + ...staleListSession, + inFlightGeneration: { + ...staleListSession.inFlightGeneration, + streamingText: "authoritative partial", + streamingThinking: "authoritative reasoning", + replayFromEventId: 23, + }, + }, + }); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(mockAttachChatStream).toHaveBeenCalledTimes(1); + expect(mockAttachChatStream).toHaveBeenCalledWith( + staleListSession.id, + expect.any(Object), + undefined, + { lastEventId: 23 }, + ); + expect(result.current.streamingText).toBe("authoritative partial"); + expect(result.current.streamingThinking).toBe("authoritative reasoning"); + }); + }); + + it("FN-8504 ignores an earlier A refresh after rapid A → B → A re-entry", async () => { + const sessionA = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: false, + inFlightGeneration: null, + }; + const sessionB = { + ...makeSession({ id: "session-002", agentId: "agent-002" }), + isGenerating: false, + inFlightGeneration: null, + }; + const oldARefresh = createDeferredPromise<{ session: ChatSession }>(); + const currentARefresh = createDeferredPromise<{ session: ChatSession }>(); + let aFetches = 0; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] }); + mockFetchChatSession.mockImplementation((id) => { + if (id === sessionA.id) { + aFetches += 1; + return aFetches === 1 ? oldARefresh.promise : currentARefresh.promise; + } + return Promise.resolve({ session: sessionB }); + }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat()); + await waitFor(() => expect(result.current.sessions).toHaveLength(2)); + + act(() => { + result.current.selectSession(sessionA.id); + result.current.selectSession(sessionB.id); + result.current.selectSession(sessionA.id); + }); + + await waitFor(() => expect(aFetches).toBe(2)); + + await act(async () => { + oldARefresh.resolve({ + session: { + ...sessionA, + isGenerating: true, + inFlightGeneration: { + status: "generating", + streamingText: "obsolete partial", + streamingThinking: "obsolete reasoning", + toolCalls: [], + replayFromEventId: 31, + updatedAt: "2026-07-20T18:00:00.000Z", + }, + }, + }); + await Promise.resolve(); + }); + + expect(mockAttachChatStream).not.toHaveBeenCalled(); + + await act(async () => { + currentARefresh.resolve({ + session: { + ...sessionA, + isGenerating: true, + inFlightGeneration: { + status: "generating", + streamingText: "current partial", + streamingThinking: "current reasoning", + toolCalls: [], + replayFromEventId: 32, + updatedAt: "2026-07-20T18:01:00.000Z", + }, + }, + }); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(mockAttachChatStream).toHaveBeenCalledTimes(1); + expect(mockAttachChatStream).toHaveBeenCalledWith(sessionA.id, expect.any(Object), undefined, { lastEventId: 32 }); + expect(result.current.streamingText).toBe("current partial"); + expect(result.current.streamingThinking).toBe("current reasoning"); + }); + }); + it("fetches session on visible return only when no live stream and swallows reconnect failures", async () => { const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), @@ -2708,9 +2847,9 @@ describe("useChat", () => { mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); mockAttachChatStream.mockReturnValue(null as never); - mockFetchChatSession.mockResolvedValue({ - session: { ...session, isGenerating: false }, - }); + mockFetchChatSession + .mockResolvedValueOnce({ session }) + .mockResolvedValue({ session: { ...session, isGenerating: false } }); const { result } = renderHook(() => useChat("proj-123")); @@ -3775,6 +3914,69 @@ describe("useChat", () => { }); }); + it("FN-8504 waits for the authoritative cursor when an SSE update races session re-entry", async () => { + const staleSession = { + ...makeSession({ id: "session-reentry-race", agentId: "agent-001" }), + isGenerating: false, + inFlightGeneration: null, + }; + const staleSseSession = { + ...staleSession, + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "stale partial", + streamingThinking: "", + toolCalls: [], + replayFromEventId: 4, + updatedAt: "2026-07-22T19:05:00.000Z", + }, + }; + const authoritativeSession = { + ...staleSseSession, + inFlightGeneration: { + ...staleSseSession.inFlightGeneration, + streamingText: "authoritative partial", + streamingThinking: "authoritative reasoning", + replayFromEventId: 23, + }, + }; + const refresh = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleSession] }); + mockFetchChatSession.mockReturnValueOnce(refresh.promise); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat("proj-123")); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => result.current.selectSession(staleSession.id)); + act(() => { + subscribeHandler["chat:session:updated"]?.({ + data: JSON.stringify(staleSseSession), + } as MessageEvent); + }); + + expect(mockAttachChatStream).not.toHaveBeenCalled(); + expect(result.current.isStreaming).toBe(false); + + await act(async () => { + refresh.resolve({ session: authoritativeSession }); + await refresh.promise; + }); + + await waitFor(() => { + expect(mockAttachChatStream).toHaveBeenCalledTimes(1); + expect(mockAttachChatStream).toHaveBeenCalledWith( + staleSession.id, + expect.any(Object), + "proj-123", + { lastEventId: 23 }, + ); + expect(result.current.streamingText).toBe("authoritative partial"); + expect(result.current.streamingThinking).toBe("authoritative reasoning"); + }); + }); + it("FN-6599 keeps restored main-chat prior thread visible during selectSession recovery attach", async () => { const generatingSession = { ...makeSession({ @@ -3801,6 +4003,7 @@ describe("useChat", () => { mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] }); + mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst }); const { result } = renderHook(() => useChat("proj-123")); @@ -3873,6 +4076,7 @@ describe("useChat", () => { cacheMessages("proj-123", generatingSession.id, [priorUser, laterAssistant]); mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] }); + mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); mockFetchChatMessages.mockResolvedValueOnce({ messages: [laterAssistant, persistedUser, priorUser] }); const { result } = renderHook(() => useChat("proj-123")); @@ -4187,6 +4391,7 @@ describe("useChat", () => { }; let attachedHandlers: StreamAppendHandlers | undefined; mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); mockAttachChatStream.mockImplementation((_sessionId, handlers) => { attachedHandlers = handlers; @@ -5171,6 +5376,7 @@ describe("useChat", () => { describe("FN-3336: streaming state recovery on reload", () => { it("does not re-select and reset active session on subsequent session refreshes", async () => { const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true }; + mockFetchChatSession.mockResolvedValueOnce({ session }); mockGetScopedItem.mockReturnValue("session-001"); mockFetchChatSessions .mockResolvedValueOnce({ sessions: [session] }) @@ -5183,7 +5389,9 @@ describe("useChat", () => { expect(result.current.activeSession?.id).toBe("session-001"); }); - expect(mockFetchChatMessages).toHaveBeenCalledTimes(1); + // Authoritative re-entry reuses the attach transcript load, then later session-list + // refreshes must not select/reset the active thread again. + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2); await act(async () => { await result.current.refreshSessions(); @@ -5194,7 +5402,7 @@ describe("useChat", () => { }); // A sessions refresh should not auto-reselect/reset the active thread. - expect(mockFetchChatMessages).toHaveBeenCalledTimes(1); + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2); }); it("preserves streaming text/thinking/tool state across sessions refresh", async () => { @@ -5265,6 +5473,7 @@ describe("useChat", () => { }, }; mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockResolvedValueOnce({ session }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); const { result } = renderHook(() => useChat("proj-123")); @@ -5308,7 +5517,20 @@ describe("useChat", () => { const otherSession = makeSession({ id: "session-002", agentId: "agent-002", title: "Other" }); const handlers: StreamAppendHandlers[] = []; const closeFirstStream = vi.fn(); + const resumedGeneration = { + ...generatingSession, + inFlightGeneration: { + ...generatingSession.inFlightGeneration, + streamingText: "Hello world", + streamingThinking: "plan next ", + replayFromEventId: 6, + }, + }; mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession, otherSession] }); + mockFetchChatSession + .mockResolvedValueOnce({ session: generatingSession }) + .mockResolvedValueOnce({ session: otherSession }) + .mockResolvedValueOnce({ session: resumedGeneration }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => { handlers.push(nextHandlers); @@ -5390,6 +5612,7 @@ describe("useChat", () => { it("sets isStreaming=true when selecting a session with isGenerating=true", async () => { const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true }; mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockResolvedValueOnce({ session }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); const { result } = renderHook(() => useChat("proj-123")); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 1ff504393f..4bfd6fb038 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -445,6 +445,10 @@ export function useChat( const activeSessionRef = useRef(activeSession); const messagesRef = useRef(messages); const isStreamingRef = useRef(isStreaming); + // Incremented for every selection, including A → B → A. Session ids alone cannot + // distinguish an old A refresh from the newly re-entered A thread. + const activeSessionSelectionRef = useRef(0); + const authoritativeSelectionRefreshRef = useRef<{ sessionId: string; version: number } | null>(null); sessionsRef.current = sessions; activeSessionRef.current = activeSession; messagesRef.current = messages; @@ -731,7 +735,30 @@ export function useChat( return true; } + const pendingRefresh = authoritativeSelectionRefreshRef.current; + if ( + pendingRefresh?.sessionId === sessionId + && pendingRefresh.version === activeSessionSelectionRef.current + ) { + /* + FNXC:ChatStreaming 2026-07-22-19:05: + An SSE list update can arrive between selection and its authoritative session read. + Do not let that potentially stale row claim stream ownership: the authoritative snapshot + owns the cursor and must seed the restored bubble before any attach path can continue. + */ + return false; + } + cancelledByUserRef.current = false; + /* + FNXC:ChatStreaming 2026-07-20-18:45: + A closed stream can still deliver terminal callbacks. Bind each attachment to the selected + session incarnation so completion or errors from a departed thread cannot clear the restored + bubble, Stop control, or transcript of a thread re-entered afterward. + */ + const attachmentSelectionVersion = activeSessionSelectionRef.current; + const ownsAttachedSession = () => + activeSessionSelectionRef.current === attachmentSelectionVersion && activeSessionRef.current?.id === sessionId; const currentMessages = messagesRef.current; const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId; lastAttachedGenerationRef.current = { @@ -758,6 +785,13 @@ export function useChat( setStreamingThinking(inFlightGeneration.streamingThinking); setStreamingToolCalls(inFlightGeneration.toolCalls); } + /* + FNXC:ChatStreaming 2026-07-22-19:20: + Re-entry must expose Working atomically to same-tick SSE and transcript callbacks. React has + not committed setIsStreaming when attachChatStream replays, so synchronize the ownership ref + before attaching; otherwise an empty stale transcript can erase the restored prior thread. + */ + isStreamingRef.current = true; setIsStreaming(true); const { handlers } = createChatStreamHandlers({ @@ -779,6 +813,7 @@ export function useChat( setActiveSession((prev) => prev && prev.id === fallbackSessionId ? { ...prev, ...nextModel } : prev); }, onDone: () => { + if (!ownsAttachedSession()) return; setStreamingText(""); setStreamingThinking(""); setStreamingToolCalls([]); @@ -790,6 +825,7 @@ export function useChat( flushPendingMessage(); }, onError: (data) => { + if (!ownsAttachedSession()) return; setStreamingText(""); setStreamingThinking(""); setStreamingToolCalls([]); @@ -831,14 +867,16 @@ export function useChat( if (id && currentActiveSessionId === id && !sessionOverride) { return; } - // Close any existing stream + const selectionVersion = ++activeSessionSelectionRef.current; + authoritativeSelectionRefreshRef.current = id ? { sessionId: id, version: selectionVersion } : null; + // Close any existing stream before its transient state is reset. if (streamRef.current) { streamRef.current.close(); streamRef.current = null; } lastAttachedGenerationRef.current = null; - // Find and set active session + // Find and set active session while its authoritative state hydrates. const session = sessionOverride ?? sessions.find((s) => s.id === id); setActiveSession(session || null); activeSessionRef.current = session || null; @@ -846,49 +884,81 @@ export function useChat( if (id) { void fetchChatSession(id, projectId) .then(({ session: refreshedSession }) => { - if (!refreshedSession.isGenerating) { - return; - } - // Only act if the user hasn't navigated away from this session - // while the authoritative refresh was in flight. - if (activeSessionRef.current?.id !== id) { - return; - } - setActiveSession((prev) => { - if (!prev || prev.id !== id) { - return prev; + if ( + refreshedSession.id !== id + || activeSessionSelectionRef.current !== selectionVersion + || activeSessionRef.current?.id !== id + ) { + if ( + refreshedSession.id !== id + && authoritativeSelectionRefreshRef.current?.version === selectionVersion + ) { + authoritativeSelectionRefreshRef.current = null; + if (session?.isGenerating && !streamRef.current) { + attachIfGenerating(id, session.inFlightGeneration, { silent: true }); + } } - return { - ...prev, - ...refreshedSession, - }; - }); + return; + } + if (typeof refreshedSession.isGenerating !== "boolean") { + /* + FNXC:ChatStreaming 2026-07-20-19:20: + An omitted generation enrichment is not an authoritative idle verdict. Preserve + legacy cached recovery only for that malformed/older response; current responses + must include the boolean and therefore cannot bypass snapshot reconciliation. + */ + authoritativeSelectionRefreshRef.current = null; + if (session?.isGenerating && !streamRef.current) { + attachIfGenerating(id, session.inFlightGeneration, { silent: true }); + } + return; + } + const authoritativeSession = { ...activeSessionRef.current, ...refreshedSession }; + authoritativeSelectionRefreshRef.current = null; + setActiveSession(authoritativeSession); + /* - FNXC:ChatStreaming 2026-07-07-00:00: - FN-7656: returning to a session with an in-flight generation must restore the - working/"Thinking…" indicator immediately, even before the first response delta. - The local `sessions` cache's `isGenerating` flag is often stale (chat:session:updated - SSE payloads lack the route-level isGenerating/inFlightGeneration enrichment), and - early in a generation the server reports isGenerating:true with inFlightGeneration - still null (no delta emitted yet). Reattach on isGenerating alone via this - authoritative fetchChatSession refresh rather than requiring inFlightGeneration too; - attachIfGenerating already handles a null inFlightGeneration snapshot gracefully and - guards against double-attach via streamRef.current. + FNXC:ChatStreaming 2026-07-20-19:15: + Re-entry must wait for the authoritative session snapshot before opening a stream. + A cached list row can carry an older cursor/text/tool snapshot; attaching from it + prevents the newer refresh from reseeding or replaying correctly. The selection + incarnation guards A → B → A, while the resolved snapshot atomically supplies the + working state (including null pre-first-delta snapshots) and replay cursor. */ - if (!streamRef.current) { + if (refreshedSession.isGenerating && !streamRef.current) { + /* + FNXC:ChatStreaming 2026-07-22-19:25: + The selection transcript request can resolve empty or stale before its authoritative + generation snapshot arrives. Reattach reloads the persisted thread so re-entry keeps + prior messages visible rather than leaving a restored streaming bubble by itself. + */ attachIfGenerating(id, refreshedSession.inFlightGeneration, { silent: true }); } }) .catch(() => { - // Ignore stale-cache recovery fetch failures. + const pendingRefresh = authoritativeSelectionRefreshRef.current; + if ( + pendingRefresh?.sessionId !== id + || pendingRefresh.version !== selectionVersion + || activeSessionSelectionRef.current !== selectionVersion + || activeSessionRef.current?.id !== id + ) { + return; + } + + authoritativeSelectionRefreshRef.current = null; + // A transport failure is not an idle verdict. Retain the prior recovery behavior, + // but only for this still-current selection incarnation. + if (session?.isGenerating && !streamRef.current) { + attachIfGenerating(id, session.inFlightGeneration, { silent: true }); + } }); } - // Reset transient state resetTransientComposerState(); setHasMoreMessages(false); - // Load messages for this session + // Load messages for this session while the authoritative request is pending. if (id) { hydrateMessagesFromCache(id); loadMessages(id); @@ -896,14 +966,6 @@ export function useChat( setMessages([]); } - // Recover streaming state if the server reports an active generation. - // After a reload/HMR, the server keeps generating but the UI loses - // all streaming state. Showing "Working…" immediately tells the - // user the AI is still processing the request. - if (session?.isGenerating) { - attachIfGenerating(session.id, session.inFlightGeneration, { priorThreadLoadAlreadyStarted: true }); - } - // Persist active session to localStorage if (id) { setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId); @@ -1642,6 +1704,13 @@ export function useChat( if (!activeSession?.id || activeSession.isGenerating !== true || streamRef.current) { return; } + const pendingRefresh = authoritativeSelectionRefreshRef.current; + if ( + pendingRefresh?.sessionId === activeSession.id + && pendingRefresh.version === activeSessionSelectionRef.current + ) { + return; + } const replayFromEventId = typeof activeSession.inFlightGeneration?.replayFromEventId === "number" ? activeSession.inFlightGeneration.replayFromEventId @@ -1658,6 +1727,13 @@ export function useChat( // until generation finishes and messages can be reloaded. useEffect(() => { if (!activeSessionRef.current?.isGenerating) return; + const pendingRefresh = authoritativeSelectionRefreshRef.current; + if ( + pendingRefresh?.sessionId === activeSessionRef.current.id + && pendingRefresh.version === activeSessionSelectionRef.current + ) { + return; + } if (!streamRef.current) { attachIfGenerating(activeSessionRef.current.id, activeSessionRef.current.inFlightGeneration); @@ -1766,8 +1842,13 @@ export function useChat( const updated = prev.map((s) => (s.id === updatedSession.id ? updatedSession : s)); return sortChatSessions(updated); }); - // If this is the active session, update it too - if (activeSessionRef.current?.id === updatedSession.id) { + // If this is the active session, update it too unless selection is still awaiting + // its authoritative session snapshot. The list/SSE payload may have an older cursor. + const pendingRefresh = authoritativeSelectionRefreshRef.current; + const awaitingAuthoritativeSnapshot = + pendingRefresh?.sessionId === updatedSession.id + && pendingRefresh.version === activeSessionSelectionRef.current; + if (activeSessionRef.current?.id === updatedSession.id && !awaitingAuthoritativeSnapshot) { setActiveSession(updatedSession); if (updatedSession.isGenerating && !streamRef.current) { attachIfGenerating(updatedSession.id, updatedSession.inFlightGeneration);