FN-8062: normalize room message ordering

Normalize room chat snapshots to chronological display order.

- Reverse newest-first API snapshots before cache hydration and rendering.
- Version the room message cache and cover initial, refresh, recovery, SSE, and host layouts.
- Add a patch changeset for the chat transcript ordering fix.

Files changed:
 .changeset/fn-8062-room-message-order.md           |  7 +++
 .../components/__tests__/ChatView.rooms.test.tsx   | 26 ++++++++
 .../app/hooks/__tests__/useChatRooms.cache.test.ts |  8 +--
 .../app/hooks/__tests__/useChatRooms.test.ts       | 73 +++++++++++++++++++++-
 packages/dashboard/app/hooks/useChatRooms.ts       | 45 ++++++++-----
 packages/dashboard/app/utils/swrCache.ts           |  3 +-
 6 files changed, 140 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-8062

Fusion-Task-Lineage: 99bb313c-fb24-40e2-b33b-4811bde6d6cb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 02:41:27 -07:00
parent e87b51bd07
commit 39d76fdabe
6 changed files with 140 additions and 22 deletions

View File

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

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} {...hostProps} />);
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" };

View File

@@ -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<ChatRoomMessage[]>(`${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 () => {

View File

@@ -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<void>;
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 () => {

View File

@@ -74,6 +74,17 @@ function parseSsePayload<T>(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;

View File

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