From 3d7eaeabeeed7b2ec24e791b16d159511681ac7c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 00:26:51 -0700 Subject: [PATCH] FN-7849: reconcile streaming user-message echo to render chat attachments immediately Fix chat image/file attachments not rendering until re-entering the thread by reconciling the optimistic temp user bubble with the persisted user-message SSE echo during active streaming. - In useChat, when a persisted user-role message arrives via chat:message:added while the active session is streaming, replace the optimistic temp-* bubble with the reconciled persisted message (real id + attachment filenames) instead of leaving the temp bubble in place with no refetch. - Add regression coverage: persisted user attachment echo reconciles without duplicate/refetch, attachment-only echo reconciles content+attachments, and text-only echo still reconciles without duplicating messages. - Extend test helper makeMessage to pass through attachments overrides. Files changed: .../dashboard/app/hooks/__tests__/useChat.test.ts | 160 +++++++++++++++++++++ packages/dashboard/app/hooks/useChat.ts | 13 ++ 2 files changed, 173 insertions(+) Fusion-Task-Id: FN-7849 Fusion-Task-Lineage: dbabda9c-5842-4d16-bafb-776a6536e51f Co-authored-by: Fusion (runfusion.ai) --- .../app/hooks/__tests__/useChat.test.ts | 160 ++++++++++++++++++ packages/dashboard/app/hooks/useChat.ts | 13 ++ 2 files changed, 173 insertions(+) diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index b9515756c1..7757b12ada 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -84,6 +84,7 @@ function makeMessage(overrides: Partial & Pick { }); }); + it("reconciles persisted user attachment echo during streaming without refetch or duplicate", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages.mockResolvedValueOnce({ messages: [] }); + + mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ close: vi.fn(), isConnected: () => true })); + + 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.activeSession?.id).toBe("session-001")); + const fetchCountAfterSelect = mockFetchChatMessages.mock.calls.length; + const uploadedFiles = [ + new File(["image-bytes"], "screenshot.png", { type: "image/png" }), + new File(["notes"], "notes.txt", { type: "text/plain" }), + ]; + + act(() => { + result.current.sendMessage("Hi", uploadedFiles); + }); + + await waitFor(() => { + expect(result.current.isStreaming).toBe(true); + const userMessages = result.current.messages.filter((message) => message.role === "user"); + expect(userMessages).toHaveLength(1); + expect(userMessages[0]?.id.startsWith("temp-")).toBe(true); + expect(userMessages[0]?.attachments).toBeUndefined(); + }); + + const persistedAttachments = [ + { + id: "att-image-001", + filename: "2026-07-12T00-00-00_screenshot.png", + originalName: "screenshot.png", + mimeType: "image/png", + size: 11, + createdAt: "2026-07-12T00:00:00.000Z", + }, + { + id: "att-file-001", + filename: "2026-07-12T00-00-00_notes.txt", + originalName: "notes.txt", + mimeType: "text/plain", + size: 5, + createdAt: "2026-07-12T00:00:00.000Z", + }, + ]; + const persistedEcho = makeMessage({ + id: "msg-user-001", + sessionId: "session-001", + role: "user", + content: "Hi", + attachments: persistedAttachments, + }); + + act(() => { + subscribeHandler["chat:message:added"]?.({ + data: JSON.stringify(persistedEcho), + } as MessageEvent); + }); + + await waitFor(() => { + const userMessages = result.current.messages.filter((message) => message.role === "user"); + expect(userMessages).toHaveLength(1); + expect(userMessages[0]?.id).toBe("msg-user-001"); + expect(userMessages[0]?.attachments).toEqual(persistedAttachments); + }); + expect(mockFetchChatMessages).toHaveBeenCalledTimes(fetchCountAfterSelect); + }); + + it("reconciles attachment-only persisted user echo during streaming", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages.mockResolvedValueOnce({ messages: [] }); + mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ close: vi.fn(), isConnected: () => true })); + + 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.activeSession?.id).toBe("session-001")); + + act(() => { + result.current.sendMessage("", [new File(["image-bytes"], "screenshot.png", { type: "image/png" })]); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + const persistedAttachments = [ + { + id: "att-image-only-001", + filename: "2026-07-12T00-00-00_screenshot.png", + originalName: "screenshot.png", + mimeType: "image/png", + size: 11, + createdAt: "2026-07-12T00:00:00.000Z", + }, + ]; + act(() => { + subscribeHandler["chat:message:added"]?.({ + data: JSON.stringify(makeMessage({ + id: "msg-user-attachment-only", + sessionId: "session-001", + role: "user", + content: "", + attachments: persistedAttachments, + })), + } as MessageEvent); + }); + + await waitFor(() => { + const userMessages = result.current.messages.filter((message) => message.role === "user"); + expect(userMessages).toHaveLength(1); + expect(userMessages[0]?.id).toBe("msg-user-attachment-only"); + expect(userMessages[0]?.content).toBe(""); + expect(userMessages[0]?.attachments).toEqual(persistedAttachments); + }); + }); + + it("reconciles text-only persisted user echo during streaming without duplicate", async () => { + mockFetchChatSessions.mockResolvedValueOnce({ + sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], + }); + mockFetchChatMessages.mockResolvedValueOnce({ messages: [] }); + mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ close: vi.fn(), isConnected: () => true })); + + 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.activeSession?.id).toBe("session-001")); + + act(() => { + result.current.sendMessage("Plain text only"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + + act(() => { + subscribeHandler["chat:message:added"]?.({ + data: JSON.stringify(makeMessage({ + id: "msg-user-text-only", + sessionId: "session-001", + role: "user", + content: "Plain text only", + })), + } as MessageEvent); + }); + + await waitFor(() => { + const userMessages = result.current.messages.filter((message) => message.role === "user"); + expect(userMessages).toHaveLength(1); + expect(userMessages[0]?.id).toBe("msg-user-text-only"); + expect(userMessages[0]?.attachments).toBeUndefined(); + }); + }); + it("dedupes optimistic user message when persisted user echo arrives after done", async () => { mockFetchChatSessions.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-001", agentId: "agent-001" })], diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 25909532fa..86d524f499 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -1563,6 +1563,19 @@ export function useChat( return; } + /** + * FNXC:ChatAttachments 2026-07-12-00:00: + * FN-7849 requires direct-chat uploaded attachments to render immediately after send. During streaming, reconcile the optimistic temp user bubble with the persisted user-message SSE echo because that echo carries the real id and attachment filenames; otherwise images/files only appear after leaving and re-entering the thread. + */ + if ( + activeSessionRef.current?.id === message.sessionId && + isStreamingRef.current && + message.role === "user" + ) { + setMessages((prev) => reconcileOptimisticSentMessage(prev, message)); + 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.