diff --git a/.changeset/fn-7656-chat-reattach-working-state.md b/.changeset/fn-7656-chat-reattach-working-state.md new file mode 100644 index 0000000000..3730df9a71 --- /dev/null +++ b/.changeset/fn-7656-chat-reattach-working-state.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore the chat "Working…" indicator immediately when returning to a session with an active generation. +category: fix +dev: `useChat.ts` `selectSession` now reattaches on the authoritative `fetchChatSession` refresh whenever `isGenerating===true`, instead of requiring a populated `inFlightGeneration` snapshot that is null pre-first-delta. Guards against races (stale active session, already-open stream) and reuses `attachIfGenerating` (FN-7656). diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index b7ac191544..ea3c672a73 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -1642,6 +1642,119 @@ describe("useChat", () => { }); }); + it("FN-7656 reattaches and shows working state on refresh reporting isGenerating with no inFlightGeneration snapshot yet (pre-first-delta)", async () => { + // Regression: early in a generation the server reports isGenerating:true + // with inFlightGeneration still null (no delta emitted yet). The stale + // local `sessions` cache also reports isGenerating:false. selectSession's + // authoritative fetchChatSession refresh must reattach on isGenerating + // alone, without waiting for an inFlightGeneration snapshot. + const staleSession = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: false, + inFlightGeneration: null, + }; + const generatingSessionNoSnapshot = { + ...staleSession, + isGenerating: true, + inFlightGeneration: null, + }; + + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleSession] }); + mockFetchChatSession.mockResolvedValueOnce({ session: generatingSessionNoSnapshot }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat()); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(mockAttachChatStream).toHaveBeenCalledTimes(1); + expect(mockAttachChatStream).toHaveBeenCalledWith("session-001", expect.any(Object), undefined, {}); + expect(result.current.isStreaming).toBe(true); + }); + }); + + it("FN-7656 does not reattach when the authoritative refresh reports isGenerating:false", async () => { + const session = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: false, + inFlightGeneration: null, + }; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockResolvedValueOnce({ session: { ...session, isGenerating: false, inFlightGeneration: null } }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat()); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined); + }); + + expect(mockAttachChatStream).not.toHaveBeenCalled(); + expect(result.current.isStreaming).toBe(false); + }); + + it("FN-7656 does not reattach to a session the user has already navigated away from before the refresh resolves", async () => { + const sessionA = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: false, + inFlightGeneration: null, + }; + const sessionB = { + ...makeSession({ id: "session-002", agentId: "agent-001" }), + isGenerating: false, + inFlightGeneration: null, + }; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] }); + const deferredRefresh = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSession.mockReturnValueOnce(deferredRefresh.promise); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat()); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(2); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined); + }); + + // User navigates away to session-002 before the session-001 refresh resolves. + act(() => { + result.current.selectSession("session-002"); + }); + + await act(async () => { + deferredRefresh.resolve({ + session: { ...sessionA, isGenerating: true, inFlightGeneration: null }, + }); + await Promise.resolve(); + }); + + expect(mockAttachChatStream).not.toHaveBeenCalled(); + expect(result.current.isStreaming).toBe(false); + expect(result.current.activeSession?.id).toBe("session-002"); + }); + 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" }), diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 6b8238b803..05e513cfb7 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -757,7 +757,12 @@ export function useChat( if (id) { void fetchChatSession(id, projectId) .then(({ session: refreshedSession }) => { - if (!refreshedSession.isGenerating || !refreshedSession.inFlightGeneration) { + 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) => { @@ -769,6 +774,21 @@ export function useChat( ...refreshedSession, }; }); + /* + 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. + */ + if (!streamRef.current) { + attachIfGenerating(id, refreshedSession.inFlightGeneration, { silent: true }); + } }) .catch(() => { // Ignore stale-cache recovery fetch failures.