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:
@@ -813,8 +813,8 @@ describe("useQuickChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
expect(mockFetchChatMessages.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(mockFetchChatMessages).toHaveBeenLastCalledWith("session-existing", { limit: 50 }, "proj-123");
|
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 existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
let onErrorHandler: ((data: string) => void) | undefined;
|
let onErrorHandler: ((data: string) => void) | undefined;
|
||||||
|
|
||||||
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||||
|
mockFetchChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
@@ -943,14 +944,15 @@ describe("useQuickChat", () => {
|
|||||||
await result.current.switchSession("agent-001");
|
await result.current.switchSession("agent-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(act(async () => {
|
await act(async () => {
|
||||||
const sendPromise = result.current.sendMessage("Hello");
|
const sendPromise = result.current.sendMessage("Hello");
|
||||||
onErrorHandler?.("Load failed");
|
onErrorHandler?.("Load failed");
|
||||||
await sendPromise;
|
await sendPromise;
|
||||||
})).rejects.toThrow("Load failed");
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
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 () => {
|
it("still shows toast for non-suspension errors regardless of visibility", async () => {
|
||||||
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|||||||
@@ -269,7 +269,11 @@ export function useQuickChat(
|
|||||||
[projectId],
|
[projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const attachIfGenerating = useCallback((sessionId: string, inFlightGeneration?: ChatInFlightGenerationState | null) => {
|
const attachIfGenerating = useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
inFlightGeneration?: ChatInFlightGenerationState | null,
|
||||||
|
options?: { silent?: boolean },
|
||||||
|
) => {
|
||||||
if (streamRef.current || !sessionId) {
|
if (streamRef.current || !sessionId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -289,7 +293,7 @@ export function useQuickChat(
|
|||||||
setStreamingThinking,
|
setStreamingThinking,
|
||||||
setStreamingToolCalls,
|
setStreamingToolCalls,
|
||||||
cancelStreamingFlushesRef,
|
cancelStreamingFlushesRef,
|
||||||
addToast,
|
addToast: options?.silent ? undefined : addToast,
|
||||||
onFallbackSession: (data, fallbackSessionId) => {
|
onFallbackSession: (data, fallbackSessionId) => {
|
||||||
const nextModel = parseModelDescriptor(data.fallbackModel);
|
const nextModel = parseModelDescriptor(data.fallbackModel);
|
||||||
setSessions((prev) => prev.map((session) =>
|
setSessions((prev) => prev.map((session) =>
|
||||||
@@ -316,7 +320,9 @@ export function useQuickChat(
|
|||||||
isStreamingRef.current = false;
|
isStreamingRef.current = false;
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
|
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) => {
|
void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((resp) => {
|
||||||
setMessages(resp.messages.map(mapChatMessageToInfo));
|
setMessages(resp.messages.map(mapChatMessageToInfo));
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -613,6 +619,43 @@ export function useQuickChat(
|
|||||||
const sendMessageRef = useRef<(content: string, attachments?: File[]) => Promise<void>>(() => Promise.resolve());
|
const sendMessageRef = useRef<(content: string, attachments?: File[]) => Promise<void>>(() => Promise.resolve());
|
||||||
const visibilitySuspension = useTabVisibilitySuspension();
|
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.
|
* Send a message using SSE streaming.
|
||||||
* @param content message text content
|
* @param content message text content
|
||||||
@@ -721,12 +764,18 @@ export function useQuickChat(
|
|||||||
console.error("[useQuickChat] Stream error:", data);
|
console.error("[useQuickChat] Stream error:", data);
|
||||||
|
|
||||||
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
|
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
|
||||||
const shouldSuppressSuspensionError = typeof data === "string"
|
const shouldSuppressSuspensionError = isLikelyTabSuspensionError(errorMessage);
|
||||||
&& isLikelyTabSuspensionError(data)
|
|
||||||
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
|
|
||||||
|
|
||||||
if (shouldSuppressSuspensionError) {
|
if (shouldSuppressSuspensionError) {
|
||||||
console.info("[useQuickChat] Suppressed tab-suspension stream error:", data);
|
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();
|
sendCompletionRef.current?.resolve();
|
||||||
} else {
|
} else {
|
||||||
addToast?.(errorMessage, "error");
|
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(() => {});
|
void completionPromise.catch(() => {});
|
||||||
return completionPromise;
|
return completionPromise;
|
||||||
},
|
},
|
||||||
[activeSession, projectId, addToast, reloadMessages, visibilitySuspension],
|
[activeSession, projectId, addToast, reloadMessages, reconnectSessionSilently],
|
||||||
);
|
);
|
||||||
|
|
||||||
sendMessageRef.current = sendMessage;
|
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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user