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",