fix(FN-3838): suppress suspended-tab chat load failure toasts
- Add visibility suspension hook to detect tab-hidden periods during chat loading - Update useChat and useQuickChat to suppress load-failed toasts triggered by tab suspension resumes - Expand hook test coverage for suspension timing and toast suppression behavior - Add plugin authoring documentation updates and include FN-3838 changeset Fusion-Task-Id: FN-3838
This commit is contained in:
8
.changeset/fn-3838-chat-tab-suspension.md
Normal file
8
.changeset/fn-3838-chat-tab-suspension.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Main chat no longer surfaces a confusing "Load failed" error banner when the
|
||||||
|
browser tab is backgrounded during a streaming reply. Tab-suspension network
|
||||||
|
errors are now treated as benign interruptions and the conversation silently
|
||||||
|
reconciles with the server on tab return.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* search/filter, and pagination.
|
* search/filter, and pagination.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useChat } from "../useChat";
|
import { useChat } from "../useChat";
|
||||||
import * as apiModule from "../../api";
|
import * as apiModule from "../../api";
|
||||||
@@ -79,6 +79,14 @@ function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => state,
|
||||||
|
});
|
||||||
|
fireEvent(document, new Event("visibilitychange"));
|
||||||
|
};
|
||||||
|
|
||||||
describe("useChat", () => {
|
describe("useChat", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -97,6 +105,7 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads sessions on mount", async () => {
|
it("loads sessions on mount", async () => {
|
||||||
@@ -636,6 +645,157 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses Load failed toast when tab is hidden and reconciles messages", async () => {
|
||||||
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendMessage("Hello!");
|
||||||
|
errorHandler?.("Load failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
|
||||||
|
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Load failed toast when tab stays visible", async () => {
|
||||||
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
|
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("visible");
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendMessage("Hello!");
|
||||||
|
errorHandler?.("Load failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Load failed", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses Failed to fetch shortly after hidden to visible transition", async () => {
|
||||||
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
|
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(() => {
|
||||||
|
setDocumentVisibilityState("visible");
|
||||||
|
result.current.selectSession("session-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.activeSession?.id).toBe("session-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendMessage("Hello!");
|
||||||
|
errorHandler?.("Failed to fetch");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith("Failed to fetch", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows toast for non-suspension errors regardless of visibility", async () => {
|
||||||
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendMessage("Hello!");
|
||||||
|
errorHandler?.("Request failed: 500");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Request failed: 500", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("onFallback updates the selected session model, persists fallback metadata, and shows a warning toast", async () => {
|
it("onFallback updates the selected session model, persists fallback metadata, and shows a warning toast", async () => {
|
||||||
const session = makeSession({
|
const session = makeSession({
|
||||||
id: "session-001",
|
id: "session-001",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, fireEvent, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ChatSession } from "@fusion/core";
|
import type { ChatSession } from "@fusion/core";
|
||||||
import * as apiModule from "../../api";
|
import * as apiModule from "../../api";
|
||||||
import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
|
import { FN_AGENT_ID, useQuickChat } from "../useQuickChat";
|
||||||
@@ -36,6 +36,14 @@ function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => state,
|
||||||
|
});
|
||||||
|
fireEvent(document, new Event("visibilitychange"));
|
||||||
|
};
|
||||||
|
|
||||||
describe("useQuickChat", () => {
|
describe("useQuickChat", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -52,6 +60,11 @@ describe("useQuickChat", () => {
|
|||||||
mockCancelChatResponse.mockResolvedValue({ success: true });
|
mockCancelChatResponse.mockResolvedValue({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("sendMessage returns a promise that resolves on stream completion", async () => {
|
it("sendMessage returns a promise that resolves on stream completion", async () => {
|
||||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||||
@@ -874,6 +887,131 @@ describe("useQuickChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses Load failed toast when tab is hidden", async () => {
|
||||||
|
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||||
|
const addToast = vi.fn();
|
||||||
|
let onErrorHandler: ((data: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
|
onErrorHandler = handlers.onError;
|
||||||
|
return { close: vi.fn(), isConnected: () => true };
|
||||||
|
});
|
||||||
|
|
||||||
|
setDocumentVisibilityState("hidden");
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const sendPromise = result.current.sendMessage("Hello");
|
||||||
|
onErrorHandler?.("Load failed");
|
||||||
|
await sendPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Load failed toast 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 });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
|
onErrorHandler = handlers.onError;
|
||||||
|
return { close: vi.fn(), isConnected: () => true };
|
||||||
|
});
|
||||||
|
|
||||||
|
setDocumentVisibilityState("visible");
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses Failed to fetch shortly after hidden to visible transition", async () => {
|
||||||
|
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||||
|
const addToast = vi.fn();
|
||||||
|
let onErrorHandler: ((data: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
|
onErrorHandler = handlers.onError;
|
||||||
|
return { close: vi.fn(), isConnected: () => true };
|
||||||
|
});
|
||||||
|
|
||||||
|
setDocumentVisibilityState("hidden");
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setDocumentVisibilityState("visible");
|
||||||
|
const sendPromise = result.current.sendMessage("Hello");
|
||||||
|
onErrorHandler?.("Failed to fetch");
|
||||||
|
await sendPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith("Failed to fetch", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
let onErrorHandler: ((data: string) => void) | undefined;
|
||||||
|
|
||||||
|
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
|
onErrorHandler = handlers.onError;
|
||||||
|
return { close: vi.fn(), isConnected: () => true };
|
||||||
|
});
|
||||||
|
|
||||||
|
setDocumentVisibilityState("hidden");
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123", addToast));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(act(async () => {
|
||||||
|
const sendPromise = result.current.sendMessage("Hello");
|
||||||
|
onErrorHandler?.("Request failed: 500");
|
||||||
|
await sendPromise;
|
||||||
|
})).rejects.toThrow("Request failed: 500");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Request failed: 500", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("onFallback updates the active model, persists fallback metadata, and shows a warning toast", async () => {
|
it("onFallback updates the active model, persists fallback metadata, and shows a warning toast", async () => {
|
||||||
const existingSession = makeSession({
|
const existingSession = makeSession({
|
||||||
id: "session-existing",
|
id: "session-existing",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { act, fireEvent, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "../visibilitySuspension";
|
||||||
|
|
||||||
|
describe("visibilitySuspension", () => {
|
||||||
|
it.each([
|
||||||
|
"Load failed",
|
||||||
|
"Failed to fetch",
|
||||||
|
"NetworkError when attempting to fetch resource.",
|
||||||
|
"Connection aborted",
|
||||||
|
"Connection closed unexpectedly",
|
||||||
|
"network error",
|
||||||
|
])("matches known tab-suspension transport errors: %s", (message) => {
|
||||||
|
expect(isLikelyTabSuspensionError(message)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unrelated backend errors", () => {
|
||||||
|
expect(isLikelyTabSuspensionError("Request failed: 500")).toBe(false);
|
||||||
|
expect(isLikelyTabSuspensionError("Validation error: missing key")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks recently hidden window", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => "visible",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useTabVisibilitySuspension());
|
||||||
|
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => "hidden",
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
fireEvent(document, new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => "visible",
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
fireEvent(document, new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.wasRecentlyHidden(5000)).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(6000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.wasRecentlyHidden(5000)).toBe(false);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ export interface ChatSessionInfo {
|
|||||||
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
||||||
|
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||||
|
|
||||||
export interface UseChatReturn {
|
export interface UseChatReturn {
|
||||||
// Session state
|
// Session state
|
||||||
@@ -488,6 +489,7 @@ export function useChat(
|
|||||||
const sendMessageRef = useRef<(content: string, attachments?: File[]) => void>(() => {
|
const sendMessageRef = useRef<(content: string, attachments?: File[]) => void>(() => {
|
||||||
// no-op until sendMessage is defined
|
// no-op until sendMessage is defined
|
||||||
});
|
});
|
||||||
|
const visibilitySuspension = useTabVisibilitySuspension();
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
(content: string, attachments?: File[]) => {
|
(content: string, attachments?: File[]) => {
|
||||||
@@ -591,7 +593,19 @@ export function useChat(
|
|||||||
isStreamingRef.current = false;
|
isStreamingRef.current = false;
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
console.error("[useChat] Stream error:", data);
|
console.error("[useChat] Stream error:", data);
|
||||||
addToast?.(typeof data === "string" && data.trim() ? data : "Failed to get response", "error");
|
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
|
||||||
|
const shouldSuppressSuspensionError = typeof data === "string"
|
||||||
|
&& isLikelyTabSuspensionError(data)
|
||||||
|
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
|
||||||
|
|
||||||
|
if (shouldSuppressSuspensionError) {
|
||||||
|
console.info("[useChat] Suppressed tab-suspension stream error:", data);
|
||||||
|
if (activeSession?.id) {
|
||||||
|
void loadMessages(activeSession.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addToast?.(errorMessage, "error");
|
||||||
|
}
|
||||||
|
|
||||||
if (!cancelledByUserRef.current) {
|
if (!cancelledByUserRef.current) {
|
||||||
const queuedMessage = pendingMessageRef.current.trim();
|
const queuedMessage = pendingMessageRef.current.trim();
|
||||||
@@ -606,7 +620,7 @@ export function useChat(
|
|||||||
|
|
||||||
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
|
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
|
||||||
},
|
},
|
||||||
[activeSession, projectId, refreshSessions, addToast],
|
[activeSession, projectId, refreshSessions, addToast, loadMessages, visibilitySuspension],
|
||||||
);
|
);
|
||||||
|
|
||||||
sendMessageRef.current = sendMessage;
|
sendMessageRef.current = sendMessage;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const FN_AGENT_ID = "__fn_agent__";
|
|||||||
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
||||||
|
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||||
|
|
||||||
interface ModelSelection {
|
interface ModelSelection {
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
@@ -539,6 +540,7 @@ 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();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message using SSE streaming.
|
* Send a message using SSE streaming.
|
||||||
@@ -647,8 +649,19 @@ export function useQuickChat(
|
|||||||
isStreamingRef.current = false;
|
isStreamingRef.current = false;
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
console.error("[useQuickChat] Stream error:", data);
|
console.error("[useQuickChat] Stream error:", data);
|
||||||
addToast?.(typeof data === "string" && data.trim() ? data : "Failed to get response", "error");
|
|
||||||
sendCompletionRef.current?.reject(new Error(typeof data === "string" ? data : "Failed to get response"));
|
const errorMessage = typeof data === "string" && data.trim() ? data : "Failed to get response";
|
||||||
|
const shouldSuppressSuspensionError = typeof data === "string"
|
||||||
|
&& isLikelyTabSuspensionError(data)
|
||||||
|
&& (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000));
|
||||||
|
|
||||||
|
if (shouldSuppressSuspensionError) {
|
||||||
|
console.info("[useQuickChat] Suppressed tab-suspension stream error:", data);
|
||||||
|
sendCompletionRef.current?.resolve();
|
||||||
|
} else {
|
||||||
|
addToast?.(errorMessage, "error");
|
||||||
|
sendCompletionRef.current?.reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
sendCompletionRef.current = null;
|
sendCompletionRef.current = null;
|
||||||
|
|
||||||
if (!cancelledByUserRef.current) {
|
if (!cancelledByUserRef.current) {
|
||||||
@@ -672,7 +685,7 @@ export function useQuickChat(
|
|||||||
void completionPromise.catch(() => {});
|
void completionPromise.catch(() => {});
|
||||||
return completionPromise;
|
return completionPromise;
|
||||||
},
|
},
|
||||||
[activeSession, projectId, addToast, reloadMessages],
|
[activeSession, projectId, addToast, reloadMessages, visibilitySuspension],
|
||||||
);
|
);
|
||||||
|
|
||||||
sendMessageRef.current = sendMessage;
|
sendMessageRef.current = sendMessage;
|
||||||
|
|||||||
68
packages/dashboard/app/hooks/visibilitySuspension.ts
Normal file
68
packages/dashboard/app/hooks/visibilitySuspension.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
const SUSPENSION_ERROR_PATTERNS = [
|
||||||
|
"load failed",
|
||||||
|
"failed to fetch",
|
||||||
|
"networkerror when attempting to fetch resource.",
|
||||||
|
"connection aborted",
|
||||||
|
"connection closed unexpectedly",
|
||||||
|
"network error",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isLikelyTabSuspensionError(message: string): boolean {
|
||||||
|
const normalized = message.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return SUSPENSION_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTabVisibilitySuspension() {
|
||||||
|
const lastHiddenAtRef = useRef<number | null>(null);
|
||||||
|
const lastVisibleAtRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
lastHiddenAtRef.current = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
lastVisibleAtRef.current = now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleVisibilityChange();
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isHiddenNow = useCallback(() => typeof document !== "undefined" && document.visibilityState === "hidden", []);
|
||||||
|
|
||||||
|
const wasRecentlyHidden = useCallback((windowMs = 5000): boolean => {
|
||||||
|
const hiddenAt = lastHiddenAtRef.current;
|
||||||
|
if (hiddenAt === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (isHiddenNow()) {
|
||||||
|
return now - hiddenAt <= windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleAt = lastVisibleAtRef.current;
|
||||||
|
if (visibleAt === null || visibleAt < hiddenAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return now - visibleAt <= windowMs;
|
||||||
|
}, [isHiddenNow]);
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
isHiddenNow,
|
||||||
|
wasRecentlyHidden,
|
||||||
|
}), [isHiddenNow, wasRecentlyHidden]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user