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:
7
.changeset/fn-7656-chat-reattach-working-state.md
Normal file
7
.changeset/fn-7656-chat-reattach-working-state.md
Normal 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).
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user