diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index fedfef320..15696cd89 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -12,6 +12,7 @@ import type { ChatSession, ChatMessage } from "@fusion/core"; // Mock the API module vi.mock("../../api", () => ({ fetchChatSessions: vi.fn(), + fetchChatSession: vi.fn(), createChatSession: vi.fn(), fetchChatMessages: vi.fn(), updateChatSession: vi.fn(), @@ -46,6 +47,7 @@ const mockRemoveScopedItem = vi.mocked(projectStorageModule.removeScopedItem); const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse); const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); +const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession); const mockCreateChatSession = vi.mocked(apiModule.createChatSession); const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages); const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession); @@ -93,6 +95,9 @@ describe("useChat", () => { beforeEach(() => { vi.clearAllMocks(); mockFetchChatSessions.mockResolvedValue({ sessions: [] }); + mockFetchChatSession.mockResolvedValue({ + session: makeSession({ id: "session-001", agentId: "agent-001" }), + }); mockCreateChatSession.mockResolvedValue({ session: makeSession({ id: "session-001", agentId: "agent-001", title: "New Chat" }), }); @@ -752,6 +757,7 @@ describe("useChat", () => { }, }; mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockResolvedValueOnce({ session }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); const addToast = vi.fn(); @@ -795,10 +801,11 @@ describe("useChat", () => { }); }); - it("shows Load failed toast when tab stays visible", async () => { + it("suppresses Load failed when tab stays visible and does not add failure bubble", async () => { const session = makeSession({ id: "session-001", agentId: "agent-001" }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchChatSession.mockResolvedValueOnce({ session }); const addToast = vi.fn(); let errorHandler: ((data: string | apiModule.ChatFailureInfo) => void) | undefined; @@ -828,7 +835,9 @@ describe("useChat", () => { }); await waitFor(() => { - expect(addToast).toHaveBeenCalledWith("Load failed", "error"); + expect(addToast).not.toHaveBeenCalledWith("Load failed", "error"); + expect(result.current.messages.find((m) => m.failureInfo?.summary === "Load failed")).toBeUndefined(); + expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined); }); }); @@ -870,6 +879,99 @@ describe("useChat", () => { }); }); + it("re-attaches from fetchChatSession replay id when tab becomes visible", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001" }); + const generatingSession = { + ...session, + isGenerating: true, + inFlightGeneration: { + status: "generating" as const, + streamingText: "partial", + streamingThinking: "thinking", + toolCalls: [], + replayFromEventId: 77, + updatedAt: "2026-04-08T00:00:00.000Z", + }, + }; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); + const addToast = vi.fn(); + + 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(() => { + setDocumentVisibilityState("hidden"); + setDocumentVisibilityState("visible"); + }); + + await waitFor(() => { + expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined); + expect(mockAttachChatStream).toHaveBeenCalledWith( + "session-001", + expect.any(Object), + undefined, + { lastEventId: 77 }, + ); + expect(addToast).not.toHaveBeenCalled(); + }); + }); + + it("fetches session on visible return only when no live stream and swallows reconnect failures", async () => { + const session = { + ...makeSession({ id: "session-001", agentId: "agent-001" }), + isGenerating: false, + }; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatSession.mockRejectedValueOnce(new Error("network")); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + const addToast = vi.fn(); + + 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(() => { + setDocumentVisibilityState("hidden"); + setDocumentVisibilityState("visible"); + }); + + await waitFor(() => { + expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined); + 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 session = makeSession({ id: "session-001", agentId: "agent-001" }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 3765b662d..df6bf81ee 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { fetchChatSessions, + fetchChatSession, createChatSession as apiCreateChatSession, fetchChatMessages, updateChatSession, @@ -405,7 +406,11 @@ export function useChat( setIsStreaming(false); }, []); - 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; } @@ -425,7 +430,7 @@ export function useChat( setStreamingThinking, setStreamingToolCalls, cancelStreamingFlushesRef, - addToast, + addToast: options?.silent ? undefined : addToast, onFallbackSession: (data, fallbackSessionId) => { const nextModel = parseModelDescriptor(data.fallbackModel); setSessions((prev) => prev.map((session) => @@ -450,7 +455,9 @@ export function useChat( isStreamingRef.current = false; streamRef.current = null; const failureInfo = normalizeFailureInfo(data); - addToast?.(failureInfo.summary, "error"); + if (!options?.silent) { + addToast?.(failureInfo.summary, "error"); + } void loadMessages(sessionId); }, }); @@ -625,6 +632,43 @@ export function useChat( }); const visibilitySuspension = useTabVisibilitySuspension(); + const reconnectSessionSilently = useCallback(async (sessionId: string) => { + try { + await refreshSessions(); + const refreshedSession = await fetchChatSession(sessionId, projectId); + + if (activeSessionRef.current?.id === sessionId) { + setActiveSession((prev) => { + if (!prev || prev.id !== sessionId) { + return prev; + } + return { + ...prev, + ...refreshedSession.session, + }; + }); + } + + if (refreshedSession.session.isGenerating) { + setStreamingText(""); + setStreamingThinking(""); + setStreamingToolCalls([]); + setIsStreaming(true); + isStreamingRef.current = true; + attachIfGenerating(sessionId, refreshedSession.session.inFlightGeneration, { silent: true }); + } else { + setStreamingText(""); + setStreamingThinking(""); + setStreamingToolCalls([]); + setIsStreaming(false); + isStreamingRef.current = false; + await loadMessages(sessionId); + } + } catch { + // Intentionally swallow reconnect failures for suspension-style recovery. + } + }, [attachIfGenerating, loadMessages, projectId, refreshSessions]); + const sendMessage = useCallback( (content: string, attachments?: File[]) => { if (!activeSession) return; @@ -719,9 +763,8 @@ export function useChat( }, onError: (data, tempUserMessageId) => { const failureInfo = normalizeFailureInfo(data); - const shouldSuppressSuspensionError = typeof data === "string" - && isLikelyTabSuspensionError(data) - && (visibilitySuspension.isHiddenNow() || visibilitySuspension.wasRecentlyHidden(5000)); + const suspensionMessage = typeof data === "string" ? data : failureInfo.summary; + const shouldSuppressSuspensionError = isLikelyTabSuspensionError(suspensionMessage); setMessages((prev) => { const nextMessages = prev.filter((message) => message.id !== tempUserMessageId); @@ -751,11 +794,12 @@ export function useChat( if (shouldSuppressSuspensionError) { console.info("[useChat] Suppressed tab-suspension stream error:", data); if (activeSession?.id) { - if (activeSession.isGenerating) { - attachIfGenerating(activeSession.id, activeSession.inFlightGeneration); - } else { - void loadMessages(activeSession.id); - } + setStreamingText(""); + setStreamingThinking(""); + setStreamingToolCalls([]); + setIsStreaming(true); + isStreamingRef.current = true; + void reconnectSessionSilently(activeSession.id); } } else { addToast?.(failureInfo.summary, "error"); @@ -775,7 +819,7 @@ export function useChat( streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId); }, - [activeSession, projectId, refreshSessions, addToast, loadMessages, attachIfGenerating, visibilitySuspension], + [activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently], ); sendMessageRef.current = sendMessage; @@ -825,6 +869,47 @@ export function useChat( return () => clearInterval(interval); }, [attachIfGenerating, loadMessages, projectId, activeSession]); + useEffect(() => { + const unsubscribe = visibilitySuspension.onBecameVisible(() => { + const currentSession = activeSessionRef.current; + if (!currentSession || streamRef.current) { + return; + } + + const contextVersionAtStart = projectContextVersionRef.current; + void fetchChatSession(currentSession.id, projectId) + .then((data) => { + if (projectContextVersionRef.current !== contextVersionAtStart || streamRef.current) { + 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 loadMessages(currentSession.id); + } + }) + .catch(() => { + // Intentionally silent for visibility reconnect path. + }); + }); + + return unsubscribe; + }, [attachIfGenerating, loadMessages, projectId, visibilitySuspension]); + // SSE real-time updates useEffect(() => { const contextVersionAtStart = projectContextVersionRef.current;