diff --git a/.changeset/fix-chat-progress-indicator-reload.md b/.changeset/fix-chat-progress-indicator-reload.md new file mode 100644 index 000000000..f899d31c7 --- /dev/null +++ b/.changeset/fix-chat-progress-indicator-reload.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix chat progress indicator on reload: show "Connecting…" indicator when dashboard reloads during active AI generation diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index fdb8fe099..e408b40a9 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -63,6 +63,8 @@ export type EnrichedChatSession = ChatSession & { lastMessagePreview?: string; /** Timestamp of the last message in the session */ lastMessageAt?: string; + /** Whether a generation is currently in progress for this session */ + isGenerating?: boolean; }; /** A parsed @ mention of an agent in a chat message */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index de8508cdb..c7d8ea7a7 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -39,7 +39,6 @@ import type { AgentRating, AgentRatingSummary, AgentRatingInput, - ChatSession, ChatMessage, EnrichedChatSession, Roadmap, @@ -7673,7 +7672,7 @@ export interface ChatSessionListResponse { } export interface ChatSessionResponse { - session: ChatSession; + session: EnrichedChatSession; } export interface ChatMessageListResponse { diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index e791f9462..351fc7ba2 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -1410,4 +1410,95 @@ describe("useChat", () => { }); }); }); + + describe("FN-3336: streaming state recovery on reload", () => { + it("sets isStreaming=true when selecting a session with isGenerating=true", async () => { + const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true }; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + expect(result.current.streamingText).toBe(""); + }); + }); + + it("does not set isStreaming when isGenerating is false", async () => { + const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false }; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(false); + }); + }); + + it("clears recovery streaming state when SSE delivers assistant message", async () => { + let subscribeHandler: Record void> = {}; + mockSubscribeSse.mockImplementation((_url, options) => { + if (options?.events) { + subscribeHandler = options.events as typeof subscribeHandler; + } + return () => {}; + }); + + const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true }; + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + act(() => { + result.current.selectSession("session-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + }); + + // Simulate SSE delivering the completed assistant message + const assistantMessage = makeMessage({ + id: "msg-assistant-001", + sessionId: "session-001", + role: "assistant", + content: "Generated response", + }); + + act(() => { + subscribeHandler["chat:message:added"]( + new MessageEvent("chat:message:added", { data: JSON.stringify(assistantMessage) }), + ); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(false); + expect(result.current.streamingText).toBe(""); + expect(result.current.messages.some((m) => m.id === "msg-assistant-001")).toBe(true); + }); + }); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index 5686e478a..c2e146dbf 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -7,6 +7,7 @@ import { FN_AGENT_ID, useQuickChat } from "../useQuickChat"; vi.mock("../../api", () => ({ fetchResumeChatSession: vi.fn(), fetchChatSessions: vi.fn(), + fetchChatSession: vi.fn(), createChatSession: vi.fn(), fetchChatMessages: vi.fn(), streamChatResponse: vi.fn(), @@ -15,6 +16,7 @@ vi.mock("../../api", () => ({ const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession); 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 mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse); @@ -43,6 +45,9 @@ describe("useQuickChat", () => { session: makeSession({ id: "session-001", agentId: "agent-001" }), }); mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockFetchChatSession.mockResolvedValue({ + session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false }, + }); mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); mockCancelChatResponse.mockResolvedValue({ success: true }); }); @@ -703,4 +708,80 @@ describe("useQuickChat", () => { expect(addToast).toHaveBeenCalledWith("Failed to get response", "error"); }); }); + + describe("FN-3336: streaming state recovery on reload", () => { + it("sets isStreaming=true when initializing a session with isGenerating=true", async () => { + const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true }; + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + expect(result.current.streamingText).toBe(""); + }); + }); + + it("does not set isStreaming when isGenerating is false", async () => { + const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false }; + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(false); + }); + }); + + it("clears recovery streaming state when polling detects generation complete", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true }; + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + + // After first poll, server reports generation is done and has a new assistant message + mockFetchChatSession.mockResolvedValue({ + session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false }, + }); + mockFetchChatMessages.mockResolvedValue({ + messages: [ + { id: "msg-1", sessionId: "session-001", role: "assistant", content: "Done", thinkingOutput: null, metadata: null, createdAt: new Date().toISOString() }, + ], + }); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + }); + + // Advance time to trigger the polling interval (3s) + await act(async () => { + vi.advanceTimersByTime(3500); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(false); + expect(result.current.streamingText).toBe(""); + expect(result.current.messages.some((m) => m.id === "msg-1")).toBe(true); + }); + + vi.useRealTimers(); + }); + }); }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index f249ad7b7..517d768af 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -27,6 +27,7 @@ export interface ChatSessionInfo { updatedAt: string; lastMessagePreview?: string; lastMessageAt?: string; + isGenerating?: boolean; } export interface ToolCallInfo { @@ -321,6 +322,15 @@ export function useChat(projectId?: string): UseChatReturn { setMessages([]); } + // Recover streaming state if the server reports an active generation. + // After a reload/HMR, the server keeps generating but the UI loses + // all streaming state. Showing "Connecting…" immediately tells the + // user the AI is still working. + if (session?.isGenerating) { + setIsStreaming(true); + setStreamingText(""); + } + // Persist active session to localStorage if (id) { setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId); @@ -685,6 +695,26 @@ export function useChat(projectId?: string): UseChatReturn { return; } + // Recovery mode: isStreaming is true but there's no active stream (streamRef is null). + // This happens after a page reload/HMR when the server is still generating. + // When the assistant message arrives via SSE, add it and clear the recovery state. + if ( + activeSessionRef.current?.id === message.sessionId && + isStreamingRef.current && + !streamRef.current && + message.role === "assistant" + ) { + setMessages((prev) => { + if (prev.some((m) => m.id === message.id)) return prev; + return [...prev, message]; + }); + setStreamingText(""); + setStreamingThinking(""); + setStreamingToolCalls([]); + setIsStreaming(false); + return; + } + // Only add if this is the active session AND we're not streaming // (during streaming, messages are managed locally to avoid duplicates) // Use ref to get the current value (state may not be updated yet when handler runs) diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index 50ae17c69..03ac36090 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -3,6 +3,7 @@ import type { ChatMessage, ChatSession } from "@fusion/core"; import { fetchResumeChatSession, fetchChatSessions, + fetchChatSession, createChatSession, fetchChatMessages, streamChatResponse, @@ -173,6 +174,8 @@ export function useQuickChat( const cancelledByUserRef = useRef(false); const cancelStreamingFlushesRef = useRef<(() => void) | null>(null); const pendingMessageRef = useRef(""); + const isStreamingRef = useRef(isStreaming); + isStreamingRef.current = isStreaming; const sendCompletionRef = useRef<{ resolve: () => void; reject: (error?: unknown) => void } | null>(null); // Track the current selected chat target for session management @@ -234,6 +237,14 @@ export function useQuickChat( if (existingSession) { setActiveSession(existingSession); currentSessionKeyRef.current = sessionKey; + + // Recover streaming state if server is still generating for this session. + // After a reload/HMR, the server keeps generating but the UI loses + // all streaming state. Show the "Connecting…" indicator immediately. + if (existingSession.isGenerating) { + setIsStreaming(true); + setStreamingText(""); + } } else { const newSession = await createSessionForTarget(target); setActiveSession(newSession); @@ -273,6 +284,41 @@ export function useQuickChat( } }, [activeSession, loadMessages]); + // Poll for generation completion during recovery mode. + // Recovery mode: isStreaming=true but streamRef.current is null (no local stream). + // This happens after a reload/HMR when the server is still generating. + // Poll every 3s until the server reports isGenerating=false, then reload messages + // and clear streaming state. + useEffect(() => { + if (!isStreaming || streamRef.current || !activeSession) return; + + const interval = setInterval(async () => { + // Re-check conditions inside the callback (state may have changed) + if (!isStreamingRef.current || streamRef.current || !activeSession) { + clearInterval(interval); + return; + } + + try { + const data = await fetchChatSession(activeSession.id, projectId); + if (!data.session.isGenerating) { + clearInterval(interval); + // Reload messages to pick up the completed assistant message + const msgData = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId); + setMessages(msgData.messages.map(mapChatMessageToInfo)); + setStreamingText(""); + setStreamingThinking(""); + setStreamingToolCalls([]); + setIsStreaming(false); + } + } catch { + // Silently fail - will retry on next interval + } + }, 3000); + + return () => clearInterval(interval); + }, [isStreaming, activeSession, projectId]); + // Reload messages from server (for same-session revisit) const reloadMessages = useCallback(async () => { if (!activeSession) return; diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index fb58eb0c3..23edf828f 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -1256,3 +1256,132 @@ describe("ChatManager diagnostics", () => { }); }); }); + +describe("ChatManager.isGenerating", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetChatState(); + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: "agent-1", + status: "active", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + mockChatStore.addMessage.mockReturnValue({ + id: "msg-1", + sessionId: "chat-001", + role: "user", + content: "Hello", + createdAt: new Date().toISOString(), + }); + mockSummarizeTitle.mockResolvedValue("Test Title"); + }); + + it("returns false when no generation is active", () => { + const chatManager = createChatManager(); + expect(chatManager.isGenerating("chat-001")).toBe(false); + }); + + it("returns true during an active generation", async () => { + let resolvePrompt: () => void; + const promptPromise = new Promise((resolve) => { + resolvePrompt = resolve; + }); + + __setCreateFnAgent(async () => { + await promptPromise; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Done" }] }, + }, + }; + }); + + const chatManager = createChatManager(); + + // Start the generation (don't await it — it blocks until resolvePrompt is called) + const sendPromise = chatManager.sendMessage("chat-001", "Hello"); + + // The generation should be active now + expect(chatManager.isGenerating("chat-001")).toBe(true); + expect(chatManager.isGenerating("chat-999")).toBe(false); // different session + + // Complete the generation + resolvePrompt!(); + await sendPromise; + + // Generation should be cleared + expect(chatManager.isGenerating("chat-001")).toBe(false); + }); +}); + +describe("ChatManager.getGeneratingSessionIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetChatState(); + mockSummarizeTitle.mockResolvedValue("Test Title"); + }); + + it("returns empty array when no generations are active", () => { + const chatManager = createChatManager(); + expect(chatManager.getGeneratingSessionIds()).toEqual([]); + }); + + it("returns all session IDs with active generations", async () => { + let resolvePrompt1: () => void; + let resolvePrompt2: () => void; + const promptPromise1 = new Promise((resolve) => { resolvePrompt1 = resolve; }); + const promptPromise2 = new Promise((resolve) => { resolvePrompt2 = resolve; }); + + let callCount = 0; + __setCreateFnAgent(async () => { + callCount++; + const promise = callCount === 1 ? promptPromise1 : promptPromise2; + await promise; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Done" }] }, + }, + }; + }); + + mockChatStore.getSession.mockImplementation((id: string) => ({ + id, + agentId: "agent-1", + status: "active", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + mockChatStore.addMessage.mockReturnValue({ + id: "msg-1", + sessionId: "chat-001", + role: "user", + content: "Hello", + createdAt: new Date().toISOString(), + }); + + const chatManager = createChatManager(); + + // Start two generations + const send1 = chatManager.sendMessage("chat-001", "Hello"); + const send2 = chatManager.sendMessage("chat-002", "World"); + + // Both should show as generating + const ids = chatManager.getGeneratingSessionIds(); + expect(ids).toContain("chat-001"); + expect(ids).toContain("chat-002"); + expect(ids).toHaveLength(2); + + // Complete both + resolvePrompt1!(); + resolvePrompt2!(); + await Promise.all([send1, send2]); + + expect(chatManager.getGeneratingSessionIds()).toEqual([]); + }); +}); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 22c78ab90..9107daf2f 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -922,6 +922,21 @@ export class ChatManager { return true; } + + /** + * Check whether a generation is currently in progress for the given session. + */ + isGenerating(sessionId: string): boolean { + return this.activeGenerations.has(sessionId); + } + + /** + * Return all session IDs that currently have an active generation. + * Useful for batch-enriching session lists without N+1 lookups. + */ + getGeneratingSessionIds(): string[] { + return [...this.activeGenerations.keys()]; + } } // ── Test Helpers ──────────────────────────────────────────────────────────── diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index dc4cd9d7e..c5b66a7b1 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -120,16 +120,21 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): const sessionIds = sessions.map((s) => s.id); const lastMessages = chatStore.getLastMessageForSessions(sessionIds); + // Batch-gather generating session IDs to avoid N+1 calls + const generatingIds = options?.chatManager?.getGeneratingSessionIds?.() ?? []; + const generatingSet = new Set(generatingIds); + for (const session of sessions) { const lastMessage = lastMessages.get(session.id); + const enriched: EnrichedChatSession = session; if (lastMessage) { // Truncate content to 100 chars for preview const content = lastMessage.content || ""; - const enriched: EnrichedChatSession = session; enriched.lastMessagePreview = content.length > 100 ? content.slice(0, 100) + "…" : content; enriched.lastMessageAt = lastMessage.createdAt; } + enriched.isGenerating = generatingSet.has(session.id); } } @@ -239,7 +244,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): throw notFound(`Chat session ${sessionId} not found`); } - res.json({ session }); + const enriched: EnrichedChatSession = session; + enriched.isGenerating = options?.chatManager?.isGenerating?.(sessionId) ?? false; + + res.json({ session: enriched }); } catch (err: unknown) { if (err instanceof ApiError) { throw err;