feat(FN-3946): add chat stream reattach recovery after tab suspension

Implements Step 1 of reattaching a regular chat stream when the browser tab resumes from a hidden/suspended state, with tests covering the recovery path and documentation added to the dashboard guide.

Fusion-Task-Id: FN-3946
This commit is contained in:
Fusion
2026-05-10 11:54:48 -07:00
committed by gsxdsm
parent d8fef5e4a4
commit 47c9bfdd07
3 changed files with 64 additions and 2 deletions

View File

@@ -687,6 +687,63 @@ describe("useChat", () => {
});
});
it("re-attaches suspended stream when session is still generating", async () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
inFlightGeneration: {
status: "generating" as const,
streamingText: "partial",
streamingThinking: "thinking",
toolCalls: [],
replayFromEventId: 42,
updatedAt: "2026-04-08T00:00:00.000Z",
},
};
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const addToast = vi.fn();
let errorHandler: ((data: string) => void) | undefined;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
setDocumentVisibilityState("hidden");
const { result } = renderHook(() => useChat(undefined, addToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
const messageLoadCountBeforeError = mockFetchChatMessages.mock.calls.length;
act(() => {
result.current.sendMessage("Hello!");
errorHandler?.("Load failed");
});
await waitFor(() => {
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
expect(mockAttachChatStream).toHaveBeenCalledWith(
"session-001",
expect.any(Object),
undefined,
{ lastEventId: 42 },
);
expect(mockFetchChatMessages.mock.calls.length).toBe(messageLoadCountBeforeError);
});
});
it("shows Load failed toast when tab stays visible", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });

View File

@@ -661,7 +661,11 @@ export function useChat(
if (shouldSuppressSuspensionError) {
console.info("[useChat] Suppressed tab-suspension stream error:", data);
if (activeSession?.id) {
void loadMessages(activeSession.id);
if (activeSession.isGenerating) {
attachIfGenerating(activeSession.id, activeSession.inFlightGeneration);
} else {
void loadMessages(activeSession.id);
}
}
} else {
addToast?.(errorMessage, "error");
@@ -680,7 +684,7 @@ export function useChat(
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
},
[activeSession, projectId, refreshSessions, addToast, loadMessages, visibilitySuspension],
[activeSession, projectId, refreshSessions, addToast, loadMessages, attachIfGenerating, visibilitySuspension],
);
sendMessageRef.current = sendMessage;