From e29fea38e08e873aeb6260bb861acadf7d6740a2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Jul 2026 00:05:29 -0700 Subject: [PATCH] FN-7656: restore chat working indicator when reattaching to an active generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes chat sessions not showing the working/"Thinking…" indicator when returning to a session whose generation was already in flight but hadn't emitted its first delta yet. - useChat.ts: selectSession's authoritative fetchChatSession refresh now reattaches whenever refreshedSession.isGenerating===true, instead of also requiring a populated inFlightGeneration snapshot (which is null pre-first-delta) - Added a guard so the reattach only proceeds if the user hasn't navigated away from the session while the refresh was in flight (activeSessionRef.current?.id === id) - Calls attachIfGenerating(id, refreshedSession.inFlightGeneration, { silent: true }) when no stream is already attached, reusing existing double-attach guarding - Added regression tests in useChat.test.ts covering the reattach-on-isGenerating-alone behavior and the stale-session navigation guard - Added a patch changeset documenting the fix Files changed: .changeset/fn-7656-chat-reattach-working-state.md | 7 ++ .../dashboard/app/hooks/__tests__/useChat.test.ts | 113 +++++++++++++++++++++ packages/dashboard/app/hooks/useChat.ts | 22 +++- 3 files changed, 141 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7656 Fusion-Task-Lineage: c923c16a-391f-4475-aab8-6194bbc675f7 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7656-chat-reattach-working-state.md | 7 ++ .../app/hooks/__tests__/useChat.test.ts | 113 ++++++++++++++++++ packages/dashboard/app/hooks/useChat.ts | 22 +++- 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7656-chat-reattach-working-state.md 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.