FN-7656: restore chat working indicator when reattaching to an active generation

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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 00:05:29 -07:00
parent ebe9b9fa67
commit e29fea38e0
3 changed files with 141 additions and 1 deletions

View File

@@ -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).

View File

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

View File

@@ -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.