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:
@@ -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] });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user