feat(FN-4350): complete Step 3 — mirror reconnect logic in useQuickChat

Fusion-Task-Id: FN-4350
Fusion-Task-Lineage: 11fff2c2-1cd1-4562-9b01-032a15217479
This commit is contained in:
Fusion
2026-05-13 12:16:30 -07:00
committed by gsxdsm
parent 456c880b99
commit 7aebf6c476
2 changed files with 185 additions and 14 deletions

View File

@@ -813,8 +813,8 @@ describe("useQuickChat", () => {
});
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
expect(mockFetchChatMessages).toHaveBeenLastCalledWith("session-existing", { limit: 50 }, "proj-123");
expect(mockFetchChatMessages.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50 }, "proj-123");
});
});
@@ -923,12 +923,13 @@ describe("useQuickChat", () => {
});
});
it("shows Load failed toast when tab remains visible", async () => {
it("suppresses Load failed when tab remains visible", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
let onErrorHandler: ((data: string) => void) | undefined;
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
@@ -943,14 +944,15 @@ describe("useQuickChat", () => {
await result.current.switchSession("agent-001");
});
await expect(act(async () => {
await act(async () => {
const sendPromise = result.current.sendMessage("Hello");
onErrorHandler?.("Load failed");
await sendPromise;
})).rejects.toThrow("Load failed");
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Load failed", "error");
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
expect(mockFetchChatSession).toHaveBeenCalledWith("session-existing", "proj-123");
});
});
@@ -986,6 +988,81 @@ describe("useQuickChat", () => {
});
});
it("reattaches with replayFromEventId when tab becomes visible and server is generating", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const generatingSession = {
...existingSession,
isGenerating: true,
inFlightGeneration: {
status: "generating" as const,
streamingText: "partial",
streamingThinking: "thinking",
toolCalls: [],
replayFromEventId: 17,
updatedAt: "2026-04-08T00:00:00.000Z",
},
};
const addToast = vi.fn();
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
setDocumentVisibilityState("hidden");
setDocumentVisibilityState("visible");
});
await waitFor(() => {
expect(mockAttachChatStream).toHaveBeenCalledWith(
"session-existing",
expect.any(Object),
"proj-123",
{ lastEventId: 17 },
);
expect(addToast).not.toHaveBeenCalled();
});
});
it("visibility reconnect failure is silent and only runs without a live stream", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchChatSession.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
await act(async () => {
await result.current.switchSession("agent-001");
});
act(() => {
setDocumentVisibilityState("hidden");
setDocumentVisibilityState("visible");
});
await waitFor(() => {
expect(mockFetchChatSession).toHaveBeenCalledWith("session-existing", "proj-123");
expect(addToast).not.toHaveBeenCalled();
});
mockFetchChatSession.mockClear();
act(() => {
result.current.sendMessage("Hello");
setDocumentVisibilityState("hidden");
setDocumentVisibilityState("visible");
});
expect(mockFetchChatSession).not.toHaveBeenCalled();
});
it("still shows toast for non-suspension errors regardless of visibility", async () => {
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
const addToast = vi.fn();

View File

@@ -269,7 +269,11 @@ export function useQuickChat(
[projectId],
);
const attachIfGenerating = useCallback((sessionId: string, inFlightGeneration?: ChatInFlightGenerationState | null) => {
const attachIfGenerating = useCallback((
sessionId: string,
inFlightGeneration?: ChatInFlightGenerationState | null,
options?: { silent?: boolean },
) => {
if (streamRef.current || !sessionId) {
return true;
}
@@ -289,7 +293,7 @@ export function useQuickChat(
setStreamingThinking,
setStreamingToolCalls,
cancelStreamingFlushesRef,
addToast,
addToast: options?.silent ? undefined : addToast,
onFallbackSession: (data, fallbackSessionId) => {
const nextModel = parseModelDescriptor(data.fallbackModel);
setSessions((prev) => prev.map((session) =>
@@ -316,7 +320,9 @@ export function useQuickChat(
isStreamingRef.current = false;
streamRef.current = null;
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
addToast?.(errorMessage, "error");
if (!options?.silent) {
addToast?.(errorMessage, "error");
}
void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((resp) => {
setMessages(resp.messages.map(mapChatMessageToInfo));
}).catch(() => {});
@@ -613,6 +619,43 @@ export function useQuickChat(
const sendMessageRef = useRef<(content: string, attachments?: File[]) => Promise<void>>(() => Promise.resolve());
const visibilitySuspension = useTabVisibilitySuspension();
const reconnectSessionSilently = useCallback(async (sessionId: string) => {
try {
await refreshSessions();
const refreshed = await fetchChatSession(sessionId, projectId);
const refreshedSession = refreshed.session;
if (activeSessionRef.current?.id === sessionId) {
setActiveSession((prev) => {
if (!prev || prev.id !== sessionId) {
return prev;
}
return {
...prev,
...refreshedSession,
};
});
}
if (refreshedSession.isGenerating) {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
isStreamingRef.current = true;
attachIfGenerating(sessionId, refreshedSession.inFlightGeneration, { silent: true });
} else {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
isStreamingRef.current = false;
await reloadMessages();
}
} catch {
// Intentionally silent reconnect path.
}
}, [attachIfGenerating, projectId, refreshSessions, reloadMessages]);
/**
* Send a message using SSE streaming.
* @param content message text content
@@ -721,12 +764,18 @@ export function useQuickChat(
console.error("[useQuickChat] Stream error:", data);
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
const shouldSuppressSuspensionError = typeof data === "string"
&& isLikelyTabSuspensionError(data)
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
const shouldSuppressSuspensionError = isLikelyTabSuspensionError(errorMessage);
if (shouldSuppressSuspensionError) {
console.info("[useQuickChat] Suppressed tab-suspension stream error:", data);
if (activeSession?.id) {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
isStreamingRef.current = true;
void reconnectSessionSilently(activeSession.id);
}
sendCompletionRef.current?.resolve();
} else {
addToast?.(errorMessage, "error");
@@ -743,7 +792,9 @@ export function useQuickChat(
}
}
void reloadMessages();
if (!shouldSuppressSuspensionError) {
void reloadMessages();
}
},
});
@@ -755,11 +806,54 @@ export function useQuickChat(
void completionPromise.catch(() => {});
return completionPromise;
},
[activeSession, projectId, addToast, reloadMessages, visibilitySuspension],
[activeSession, projectId, addToast, reloadMessages, reconnectSessionSilently],
);
sendMessageRef.current = sendMessage;
useEffect(() => {
const unsubscribe = visibilitySuspension.onBecameVisible(() => {
if (skipNextSessionInitRef.current) {
return;
}
const currentSession = activeSessionRef.current;
if (!currentSession || streamRef.current) {
return;
}
void fetchChatSession(currentSession.id, projectId)
.then((data) => {
if (streamRef.current || activeSessionRef.current?.id !== currentSession.id) {
return;
}
if (data.session.isGenerating) {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
isStreamingRef.current = true;
attachIfGenerating(currentSession.id, data.session.inFlightGeneration, { silent: true });
return;
}
if (isStreamingRef.current) {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
isStreamingRef.current = false;
void reloadMessages();
}
})
.catch(() => {
// Intentionally silent for visibility reconnect path.
});
});
return unsubscribe;
}, [attachIfGenerating, projectId, reloadMessages, visibilitySuspension]);
// Cleanup on unmount
useEffect(() => {
return () => {