diff --git a/.changeset/fn-8062-room-message-order.md b/.changeset/fn-8062-room-message-order.md new file mode 100644 index 0000000000..c7a0f293ea --- /dev/null +++ b/.changeset/fn-8062-room-message-order.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix chat room messages rendering out of chronological order. +category: fix +dev: Normalize newest-first room API snapshots before display and warm-cache hydration. diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index df8a1bf1eb..22b63db639 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -257,6 +257,32 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { setup(); }); + it.each([ + ["desktop", () => mockDesktopViewport(), {}], + ["mobile/narrow", () => mockMobileViewport(), {}], + ["floating/compact", () => mockDesktopViewport(), { floating: true, compactLayout: true }], + ])("renders normalized room transcript oldest-first in the %s host", async (_host, setViewport, hostProps) => { + setViewport(); + // This state is the ascending result of the newest-first API fixture covered by useChatRooms. + setup({}, { + messages: [ + { id: "room-user-hi", roomId: roomA.id, role: "user", content: "Old user Hi", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] }, + { id: "room-cto-reply", roomId: roomA.id, role: "assistant", content: "Newer CTO reply", createdAt: "2026-04-08T00:02:00.000Z", senderAgentId: "cto", mentions: [] }, + { id: "room-pm-reply", roomId: roomA.id, role: "assistant", content: "Newest PM reply", createdAt: "2026-04-08T00:02:01.000Z", senderAgentId: "pm", mentions: [] }, + ], + }); + + await renderWithAct(); + + const rendered = [ + screen.getByText("Old user Hi"), + screen.getByText("Newer CTO reply"), + screen.getByText("Newest PM reply"), + ]; + expect(rendered[0]!.compareDocumentPosition(rendered[1]!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(rendered[1]!.compareDocumentPosition(rendered[2]!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + it("renders Direct/Rooms toggle and allows room selection without message leakage", async () => { const selectRoom = vi.fn(); const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" }; diff --git a/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts b/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts index e76b30774f..001028f533 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatRooms.cache.test.ts @@ -131,21 +131,21 @@ describe("useChatRooms cache behavior", () => { expect(result.current.messages[0]?.roomId).toBe("room-b"); }); - it("SSE message add writes cache and preserves desc order", async () => { + it("SSE message add writes an ascending cache snapshot", async () => { mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [message("m2", "room-a", "newer"), message("m1", "room-a", "older")] }); const { result } = renderHook(() => useChatRooms("proj-1")); await waitFor(() => expect(result.current.rooms.length).toBeGreaterThan(0)); act(() => result.current.selectRoom("room-a")); - await waitFor(() => expect(result.current.messages.map((m) => m.id)).toEqual(["m2", "m1"])); + await waitFor(() => expect(result.current.messages.map((m) => m.id)).toEqual(["m1", "m2"])); act(() => { events["chat:room:message:added"]?.({ data: JSON.stringify(message("m3", "room-a", "latest")) } as MessageEvent); }); const cached = readCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`); - expect(cached?.map((m) => m.id)).toEqual(["m2", "m1", "m3"]); - expect(result.current.messages.map((m) => m.id)).toEqual(["m2", "m1", "m3"]); + expect(cached?.map((m) => m.id)).toEqual(["m1", "m2", "m3"]); + expect(result.current.messages.map((m) => m.id)).toEqual(["m1", "m2", "m3"]); }); it("refreshRooms uses persisted room id and warm cache", async () => { diff --git a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts index 9109f32528..a7a6762936 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts @@ -227,6 +227,77 @@ describe("useChatRooms", () => { expect(cached?.[0]).not.toHaveProperty("messages"); }); + it("normalizes descending initial room hydration to chronological state", async () => { + const active = room("room-1", "one", "2026-05-09T01:00:00.000Z"); + const descendingTranscript = [ + { ...roomMessage("assistant-pm", "room-1", "PM reply", "2026-05-09T00:02:01.000Z"), role: "assistant" as const, senderAgentId: "pm" }, + { ...roomMessage("assistant-cto", "room-1", "CTO reply", "2026-05-09T00:02:00.000Z"), role: "assistant" as const, senderAgentId: "cto" }, + roomMessage("user-hi", "room-1", "Hi", "2026-05-09T00:00:00.000Z"), + ]; + mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] }); + mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: descendingTranscript }); + const { result } = renderHook(() => useChatRooms("proj-1")); + await waitFor(() => expect(result.current.rooms).toHaveLength(1)); + + act(() => result.current.selectRoom("room-1")); + + await waitFor(() => { + expect(result.current.messages.map((message) => message.id)).toEqual(["user-hi", "assistant-cto", "assistant-pm"]); + }); + }); + + it("normalizes the descending authoritative post-send refresh and appends optimistic messages", async () => { + const active = room("room-1", "one", "2026-05-09T01:00:00.000Z"); + const descendingTranscript = [ + { ...roomMessage("assistant-reply", "room-1", "Reply", "2026-05-09T00:03:00.000Z"), role: "assistant" as const, senderAgentId: "agent-1" }, + roomMessage("posted-user", "room-1", "Hi", "2026-05-09T00:02:00.000Z"), + roomMessage("older-user", "room-1", "Earlier", "2026-05-09T00:00:00.000Z"), + ]; + mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] }); + const { result } = renderHook(() => useChatRooms("proj-1")); + await waitFor(() => expect(result.current.rooms).toHaveLength(1)); + mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] }); + act(() => result.current.selectRoom("room-1")); + await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1")); + + let resolvePost!: (value: { message: ChatRoomMessage }) => void; + mockPostChatRoomMessage.mockReturnValueOnce(new Promise((resolve) => { resolvePost = resolve; })); + mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: descendingTranscript }); + let sendPromise!: Promise; + await act(async () => { sendPromise = result.current.sendRoomMessage("Hi"); }); + expect(result.current.messages.at(-1)?.content).toBe("Hi"); + + resolvePost({ message: descendingTranscript[1]! }); + await act(async () => { await sendPromise; }); + expect(result.current.messages.map((message) => message.id)).toEqual(["older-user", "posted-user", "assistant-reply"]); + }); + + it("normalizes the descending recovery refresh and appends SSE messages at the bottom", async () => { + const active = room("room-1", "one", "2026-05-09T01:00:00.000Z"); + const descendingTranscript = [ + { ...roomMessage("assistant-reply", "room-1", "Reply", "2026-05-09T00:03:00.000Z"), role: "assistant" as const, senderAgentId: "agent-1" }, + roomMessage("recovered-user", "room-1", "Hi", "2026-05-09T00:02:00.000Z"), + roomMessage("older-user", "room-1", "Earlier", "2026-05-09T00:00:00.000Z"), + ]; + mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] }); + const { result } = renderHook(() => useChatRooms("proj-1")); + await waitFor(() => expect(result.current.rooms).toHaveLength(1)); + mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] }); + act(() => result.current.selectRoom("room-1")); + await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1")); + + mockPostChatRoomMessage.mockRejectedValueOnce(new Error("post failed")); + mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: descendingTranscript }); + await act(async () => { await expect(result.current.sendRoomMessage("Hi")).rejects.toThrow("post failed"); }); + expect(result.current.messages.map((message) => message.id)).toEqual(["older-user", "recovered-user", "assistant-reply"]); + + const sseMessage = { ...roomMessage("sse-new", "room-1", "Newest", "2026-05-09T00:04:00.000Z"), role: "assistant" as const, senderAgentId: "agent-2" }; + act(() => { + capturedEvents["chat:room:message:added"]?.({ data: JSON.stringify(sseMessage) } as MessageEvent); + }); + expect(result.current.messages.at(-1)?.id).toBe("sse-new"); + }); + it("handles room message SSE for active and inactive rooms", async () => { const older = room("room-1", "one", "2026-05-09T01:00:00.000Z"); const newer = room("room-2", "two", "2026-05-09T02:00:00.000Z"); mockFetchChatRooms.mockResolvedValueOnce({ rooms: [older, newer] }); @@ -308,7 +379,7 @@ describe("useChatRooms", () => { expect(mockPostChatRoomMessage).toHaveBeenCalledWith("room-1", { content: "hello" }, "proj-1"); expect(mockFetchChatRoomMessages).toHaveBeenLastCalledWith("room-1", { limit: 100, order: "desc" }, "proj-1"); - expect(result.current.messages.map((message) => message.id)).toEqual(["msg-user", "msg-assistant"]); + expect(result.current.messages.map((message) => message.id)).toEqual(["msg-assistant", "msg-user"]); }); it("deduplicates user message across optimistic add, SSE echo, and post resolution interleaving", async () => { diff --git a/packages/dashboard/app/hooks/useChatRooms.ts b/packages/dashboard/app/hooks/useChatRooms.ts index acb7c5fa4d..87ee189584 100644 --- a/packages/dashboard/app/hooks/useChatRooms.ts +++ b/packages/dashboard/app/hooks/useChatRooms.ts @@ -74,6 +74,17 @@ function parseSsePayload(event: MessageEvent): T | null { } } +/* +FNXC:ChatRooms 2026-07-16-00:00: +Room transcripts must render oldest-first, even though the room API fetches the newest +window first with `order: "desc"`. FN-8062 normalizes every fetched snapshot here so +optimistic, delivered, SSE, read-tracking, and render paths can consistently append/use +`messages[messages.length - 1]` as the newest message. +*/ +function toAscending(messages: ChatRoomMessage[]): ChatRoomMessage[] { + return messages.slice().reverse(); +} + function createOptimisticRoomMessage(roomId: string, content: string, attachments?: ChatAttachment[]): ChatRoomMessage { return { id: `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -205,12 +216,13 @@ export function useChatRooms( timer.mark("members-fetch"); timer.mark("messages-fetch"); writeCache(membersCacheKey(room.id), membersData.members, { maxBytes: 500_000 }); - // Snapshot mirrors server `order: desc` shape. - writeCache(messagesCacheKey(room.id), messagesData.messages, { maxBytes: 500_000 }); + const ascendingMessages = toAscending(messagesData.messages); + // Cache snapshots are normalized oldest-first so warm hydration cannot reintroduce server order. + writeCache(messagesCacheKey(room.id), ascendingMessages, { maxBytes: 500_000 }); if (activeRoomRef.current?.id === room.id) { setActiveRoomMembers(membersData.members); - setMessages(messagesData.messages); + setMessages(ascendingMessages); timer.mark("hydrate"); } } catch { @@ -349,7 +361,7 @@ export function useChatRooms( if (activeRoomRef.current?.id === roomId) { setMessages((previous) => { const next = [...previous, optimisticMessage]; - // Snapshot mirrors server `order: desc` shape. + // Cache snapshots are stored oldest-first, matching the room display contract. writeCache(messagesCacheKey(roomId), next, { maxBytes: 500_000 }); return next; }); @@ -385,36 +397,37 @@ export function useChatRooms( if (activeRoomRef.current?.id === roomId) { setMessages((previous) => { const next = reconcileOptimisticUserMessage(previous, postResult.message, optimisticMessage.id); - // Snapshot mirrors server `order: desc` shape. + // Cache snapshots are stored oldest-first, matching the room display contract. writeCache(messagesCacheKey(roomId), next, { maxBytes: 500_000 }); return next; }); } const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100, order: "desc" }, projectId); - // Snapshot mirrors server `order: desc` shape. - writeCache(messagesCacheKey(roomId), latestMessages.messages, { maxBytes: 500_000 }); + const ascendingMessages = toAscending(latestMessages.messages); + // Cache snapshots are normalized oldest-first for display and warm hydration. + writeCache(messagesCacheKey(roomId), ascendingMessages, { maxBytes: 500_000 }); if (activeRoomRef.current?.id !== roomId) { return; } - setMessages(latestMessages.messages); + setMessages(ascendingMessages); timer.mark("hydrate"); } catch (error) { let recoveredMessages: ChatRoomMessage[] | null = null; try { const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100, order: "desc" }, projectId); - recoveredMessages = latestMessages.messages; - // Snapshot mirrors server `order: desc` shape. - writeCache(messagesCacheKey(roomId), latestMessages.messages, { maxBytes: 500_000 }); + recoveredMessages = toAscending(latestMessages.messages); + // Cache snapshots are normalized oldest-first for display and warm hydration. + writeCache(messagesCacheKey(roomId), recoveredMessages, { maxBytes: 500_000 }); if (activeRoomRef.current?.id === roomId) { - setMessages(latestMessages.messages); + setMessages(recoveredMessages); timer.mark("hydrate"); } } catch { if (!userMessageDelivered && activeRoomRef.current?.id === roomId) { setMessages((previous) => { const next = previous.filter((message) => message.id !== optimisticMessage.id); - // Snapshot mirrors server `order: desc` shape. + // Cache snapshots are stored oldest-first, matching the room display contract. writeCache(messagesCacheKey(roomId), next, { maxBytes: 500_000 }); return next; }); @@ -549,7 +562,7 @@ export function useChatRooms( if (next === previous) { return previous; } - // Snapshot mirrors server `order: desc` shape. + // Cache snapshots are stored oldest-first, matching the room display contract. writeCache(messagesCacheKey(message.roomId), next, { maxBytes: 500_000 }); return next; }); @@ -560,7 +573,7 @@ export function useChatRooms( if (!message || activeRoomRef.current?.id !== message.roomId) return; setMessages((previous) => { const next = previous.map((candidate) => (candidate.id === message.id ? message : candidate)); - // Snapshot mirrors server `order: desc` shape. + // Cache snapshots are stored oldest-first, matching the room display contract. writeCache(messagesCacheKey(message.roomId), next, { maxBytes: 500_000 }); return next; }); @@ -573,7 +586,7 @@ export function useChatRooms( const next = previous.filter((message) => message.id !== payload.id); const activeRoomId = activeRoomRef.current?.id; if (activeRoomId) { - // Snapshot mirrors server `order: desc` shape. + // Cache snapshots are stored oldest-first, matching the room display contract. writeCache(messagesCacheKey(activeRoomId), next, { maxBytes: 500_000 }); } return next; diff --git a/packages/dashboard/app/utils/swrCache.ts b/packages/dashboard/app/utils/swrCache.ts index 8bbb1d5a24..fdd9c51d72 100644 --- a/packages/dashboard/app/utils/swrCache.ts +++ b/packages/dashboard/app/utils/swrCache.ts @@ -22,7 +22,8 @@ export const SWR_CACHE_KEYS = { CHAT_ROOMS: "kb-dashboard-chat-rooms-cache", CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:", CHAT_MESSAGES_PREFIX: "kb-dashboard-chat-messages-cache:", - CHAT_ROOM_MESSAGES_PREFIX: "kb-dashboard-chat-room-messages-cache:", + // FNXC:ChatRooms 2026-07-16-00:00: FN-8062 changes room message snapshots from server-descending to display-ascending; namespace the cache so an old 60-second snapshot never flashes out of order after deploy. + CHAT_ROOM_MESSAGES_PREFIX: "kb-dashboard-chat-room-messages-cache:v2:", CHAT_ROOM_MEMBERS_PREFIX: "kb-dashboard-chat-room-members-cache:", CHAT_AGENTS_MAP_PREFIX: "kb-dashboard-chat-agents-map-cache:", MODELS: "kb-dashboard-models-cache",