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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 00:26:51 -07:00
parent 0a74059113
commit 3d7eaeabee
2 changed files with 173 additions and 0 deletions

View File

@@ -84,6 +84,7 @@ function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" |
content: overrides.content,
thinkingOutput: overrides.thinkingOutput ?? null,
metadata: overrides.metadata ?? null,
attachments: overrides.attachments,
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
};
}
@@ -4038,6 +4039,165 @@ describe("useChat", () => {
});
});
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" })],

View File

@@ -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.