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:
7
.changeset/fn-8062-room-message-order.md
Normal file
7
.changeset/fn-8062-room-message-order.md
Normal 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.
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user