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.