feat(FN-5388): add warm room cache hydration with open diagnostics

Add room open performance diagnostics and warm cache hydration for room switches (FN-5388). The implementation adds a timing instrumentation utility, SWR cache constants, warm-room handoff logic in `useChatRooms`, and a documentation file covering the performance model, accompanied by regression tes

Fusion-Task-Id: FN-5388
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 20:26:28 -07:00
committed by gsxdsm
parent b936ab9bb5
commit e96cb09982
6 changed files with 622 additions and 26 deletions

View File

@@ -0,0 +1,233 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatRoom, ChatRoomMember, ChatRoomMessage } from "@fusion/core";
import { useChatRooms } from "../useChatRooms";
import * as apiModule from "../../api";
import * as sseBusModule from "../../sse-bus";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache";
vi.mock("../../api", () => ({
fetchChatRooms: vi.fn(),
createChatRoom: vi.fn(),
fetchChatRoomMembers: vi.fn(),
fetchChatRoomMessages: vi.fn(),
deleteChatRoom: vi.fn(),
postChatRoomMessage: vi.fn(),
uploadChatRoomAttachment: vi.fn(),
clearChatRoomMessages: vi.fn(),
}));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => {}) }));
vi.mock("../../utils/projectStorage", () => ({
getScopedItem: vi.fn(() => null),
setScopedItem: vi.fn(),
removeScopedItem: vi.fn(),
}));
const mockFetchChatRooms = vi.mocked(apiModule.fetchChatRooms);
const mockFetchChatRoomMembers = vi.mocked(apiModule.fetchChatRoomMembers);
const mockFetchChatRoomMessages = vi.mocked(apiModule.fetchChatRoomMessages);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
function room(id: string, updatedAt: string): ChatRoom {
return { id, name: id, slug: id, description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: updatedAt, updatedAt };
}
function member(roomId: string, agentId: string): ChatRoomMember {
return { roomId, agentId, role: "member", addedAt: "2026-05-20T00:00:00.000Z" };
}
function message(id: string, roomId: string, content: string): ChatRoomMessage {
return { id, roomId, role: "user", content, thinkingOutput: null, metadata: null, senderAgentId: null, mentions: [], createdAt: `2026-05-20T00:00:0${id.slice(-1)}.000Z` };
}
describe("useChatRooms cache behavior", () => {
let events: Record<string, (event: MessageEvent) => void> = {};
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
events = {};
mockSubscribeSse.mockImplementation((_url, sub) => {
events = sub.events ?? {};
return () => {};
});
mockFetchChatRooms.mockResolvedValue({ rooms: [room("room-a", "2026-05-20T00:00:00.000Z"), room("room-b", "2026-05-20T00:00:01.000Z")] });
mockFetchChatRoomMembers.mockResolvedValue({ members: [] });
mockFetchChatRoomMessages.mockResolvedValue({ messages: [] });
});
it("warm open paints cached data before network resolves", async () => {
const cachedMessages = [message("m2", "room-a", "cached")];
const cachedMembers = [member("room-a", "agent-1")];
writeCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`, cachedMessages, { maxBytes: 500_000 });
writeCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MEMBERS_PREFIX}proj-1:room-a`, cachedMembers, { maxBytes: 500_000 });
const membersDef = deferred<{ members: ChatRoomMember[] }>();
const messagesDef = deferred<{ messages: ChatRoomMessage[] }>();
mockFetchChatRoomMembers.mockReturnValueOnce(membersDef.promise);
mockFetchChatRoomMessages.mockReturnValueOnce(messagesDef.promise);
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBeGreaterThan(0));
act(() => result.current.selectRoom("room-a"));
expect(result.current.messages).toEqual(cachedMessages);
expect(result.current.activeRoomMembers).toEqual(cachedMembers);
expect(result.current.messagesLoading).toBe(false);
await act(async () => {
membersDef.resolve({ members: [member("room-a", "agent-2")] });
messagesDef.resolve({ messages: [message("m9", "room-a", "fresh")] });
await Promise.resolve();
});
await waitFor(() => expect(result.current.messages[0]?.id).toBe("m9"));
});
it("cold open keeps loading true until fetch resolves", async () => {
const messagesDef = deferred<{ messages: ChatRoomMessage[] }>();
mockFetchChatRoomMessages.mockReturnValueOnce(messagesDef.promise);
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBeGreaterThan(0));
act(() => result.current.selectRoom("room-a"));
expect(result.current.messagesLoading).toBe(true);
await act(async () => {
messagesDef.resolve({ messages: [message("m3", "room-a", "cold")] });
await Promise.resolve();
});
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages[0]?.id).toBe("m3");
});
it("stale-room guard prevents room A fetch from overwriting room B", async () => {
const aDef = deferred<{ messages: ChatRoomMessage[] }>();
const bDef = deferred<{ messages: ChatRoomMessage[] }>();
mockFetchChatRoomMessages.mockReturnValueOnce(aDef.promise).mockReturnValueOnce(bDef.promise);
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBeGreaterThan(1));
act(() => result.current.selectRoom("room-a"));
act(() => result.current.selectRoom("room-b"));
await act(async () => {
aDef.resolve({ messages: [message("m1", "room-a", "a") ] });
bDef.resolve({ messages: [message("m2", "room-b", "b") ] });
await Promise.resolve();
});
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-b"));
expect(result.current.messages[0]?.roomId).toBe("room-b");
});
it("SSE message add writes cache and preserves desc order", 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"]));
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"]);
});
it("refreshRooms uses persisted room id and warm cache", async () => {
writeCache(`${SWR_CACHE_KEYS.ACTIVE_CHAT_ROOM_ID}:proj-1`, "room-a", { maxBytes: 500_000 });
writeCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`, [message("m8", "room-a", "cached")], { maxBytes: 500_000 });
const messagesDef = deferred<{ messages: ChatRoomMessage[] }>();
mockFetchChatRoomMessages.mockReturnValueOnce(messagesDef.promise);
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-a"));
expect(result.current.messages.map((m) => m.id)).toEqual(["m8"]);
expect(result.current.messagesLoading).toBe(false);
await act(async () => {
messagesDef.resolve({ messages: [message("m9", "room-a", "fresh")] });
await Promise.resolve();
});
await waitFor(() => expect(result.current.messages[0]?.id).toBe("m9"));
});
it("SSE member add/remove updates members cache", async () => {
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [member("room-a", "agent-1")] });
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.activeRoomMembers.map((m) => m.agentId)).toEqual(["agent-1"]));
act(() => {
events["chat:room:member:added"]?.({ data: JSON.stringify(member("room-a", "agent-2")) } as MessageEvent);
});
expect(readCache<ChatRoomMember[]>(`${SWR_CACHE_KEYS.CHAT_ROOM_MEMBERS_PREFIX}proj-1:room-a`)?.map((m) => m.agentId)).toEqual(["agent-1", "agent-2"]);
act(() => {
events["chat:room:member:removed"]?.({ data: JSON.stringify({ roomId: "room-a", agentId: "agent-1" }) } as MessageEvent);
});
expect(readCache<ChatRoomMember[]>(`${SWR_CACHE_KEYS.CHAT_ROOM_MEMBERS_PREFIX}proj-1:room-a`)?.map((m) => m.agentId)).toEqual(["agent-2"]);
});
it("SSE message delete and clear update message cache", 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).toHaveLength(2));
act(() => {
events["chat:room:message:deleted"]?.({ data: JSON.stringify({ id: "m1" }) } as MessageEvent);
});
expect(readCache<ChatRoomMessage[]>(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`)?.map((m) => m.id)).toEqual(["m2"]);
act(() => {
events["chat:room:messages:cleared"]?.({ data: JSON.stringify({ roomId: "room-a", deletedCount: 1 }) } as MessageEvent);
});
expect(readCache<ChatRoomMessage[]>(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`)).toEqual([]);
});
it("clearRoom invalidates room message cache", async () => {
writeCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`, [message("m1", "room-a", "x")], { maxBytes: 500_000 });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBeGreaterThan(0));
await act(async () => {
await result.current.clearRoom("room-a");
});
expect(readCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`)).toEqual([]);
});
it("room deleted invalidates room caches", async () => {
writeCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`, [message("m1", "room-a", "x")], { maxBytes: 500_000 });
writeCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MEMBERS_PREFIX}proj-1:room-a`, [member("room-a", "agent-1")], { maxBytes: 500_000 });
renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled());
act(() => {
events["chat:room:deleted"]?.({ data: JSON.stringify({ id: "room-a" }) } as MessageEvent);
});
expect(readCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}proj-1:room-a`)).toEqual([]);
expect(readCache(`${SWR_CACHE_KEYS.CHAT_ROOM_MEMBERS_PREFIX}proj-1:room-a`)).toEqual([]);
});
});

View File

@@ -13,7 +13,15 @@ import {
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, SWR_LONG_MAX_AGE_MS, writeCache } from "../utils/swrCache"; import {
readCache,
SWR_CACHE_KEYS,
SWR_CHAT_ROOM_MAX_AGE_MS,
SWR_DEFAULT_MAX_AGE_MS,
SWR_LONG_MAX_AGE_MS,
writeCache,
} from "../utils/swrCache";
import { startRoomOpenTimer } from "../utils/roomOpenDiagnostics";
const ACTIVE_ROOM_STORAGE_KEY = "fusion:chat-active-room"; const ACTIVE_ROOM_STORAGE_KEY = "fusion:chat-active-room";
@@ -84,6 +92,14 @@ export function useChatRooms(
): UseChatRoomsResult { ): UseChatRoomsResult {
const roomsCacheKey = `${SWR_CACHE_KEYS.CHAT_ROOMS}:${projectId ?? "global"}`; const roomsCacheKey = `${SWR_CACHE_KEYS.CHAT_ROOMS}:${projectId ?? "global"}`;
const activeRoomCacheKey = `${SWR_CACHE_KEYS.ACTIVE_CHAT_ROOM_ID}:${projectId ?? "global"}`; const activeRoomCacheKey = `${SWR_CACHE_KEYS.ACTIVE_CHAT_ROOM_ID}:${projectId ?? "global"}`;
const messagesCacheKey = useCallback(
(roomId: string) => `${SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX}${projectId ?? "global"}:${roomId}`,
[projectId],
);
const membersCacheKey = useCallback(
(roomId: string) => `${SWR_CACHE_KEYS.CHAT_ROOM_MEMBERS_PREFIX}${projectId ?? "global"}:${roomId}`,
[projectId],
);
const [rooms, setRooms] = useState<ChatRoom[]>(() => { const [rooms, setRooms] = useState<ChatRoom[]>(() => {
const cached = readCache<ChatRoom[]>(roomsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); const cached = readCache<ChatRoom[]>(roomsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return Array.isArray(cached) ? cached : []; return Array.isArray(cached) ? cached : [];
@@ -113,7 +129,7 @@ export function useChatRooms(
projectContextVersionRef.current += 1; projectContextVersionRef.current += 1;
} }
const loadRoomData = useCallback(async (room: ChatRoom | null, clearFirst = true) => { const loadRoomData = useCallback(async (room: ChatRoom | null) => {
if (!room) { if (!room) {
setActiveRoomMembers([]); setActiveRoomMembers([]);
setMessages([]); setMessages([]);
@@ -121,25 +137,55 @@ export function useChatRooms(
return; return;
} }
if (clearFirst) { const timer = startRoomOpenTimer(room.id, { warm: false });
timer.mark("select");
const cachedMessages = readCache<ChatRoomMessage[]>(messagesCacheKey(room.id), { maxAgeMs: SWR_CHAT_ROOM_MAX_AGE_MS });
const cachedMembers = readCache<ChatRoomMember[]>(membersCacheKey(room.id), { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
const hasCachedMessages = Array.isArray(cachedMessages) && cachedMessages.length > 0;
const hasCachedMembers = Array.isArray(cachedMembers) && cachedMembers.length > 0;
if (hasCachedMessages || hasCachedMembers) {
timer.mark("cache-hit");
if (hasCachedMessages) {
setMessages(cachedMessages);
timer.mark("hydrate");
}
if (hasCachedMembers) {
setActiveRoomMembers(cachedMembers);
}
setMessagesLoading(false);
} else {
setMessages([]); setMessages([]);
setMessagesLoading(true);
} }
setMessagesLoading(true);
try { try {
const [membersData, messagesData] = await Promise.all([ const [membersData, messagesData] = await Promise.all([
fetchChatRoomMembers(room.id, projectId), fetchChatRoomMembers(room.id, projectId),
fetchChatRoomMessages(room.id, { limit: 100, order: "desc" }, projectId), fetchChatRoomMessages(room.id, { limit: 100, order: "desc" }, projectId),
]); ]);
setActiveRoomMembers(membersData.members); timer.mark("members-fetch");
setMessages(messagesData.messages); 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 });
if (activeRoomRef.current?.id === room.id) {
setActiveRoomMembers(membersData.members);
setMessages(messagesData.messages);
timer.mark("hydrate");
}
} catch { } catch {
setActiveRoomMembers([]); if (!hasCachedMessages && !hasCachedMembers) {
setMessages([]); setActiveRoomMembers([]);
setMessages([]);
}
} finally { } finally {
setMessagesLoading(false); setMessagesLoading(false);
timer.complete({ warm: hasCachedMessages, membersCached: hasCachedMembers });
} }
}, [projectId]); }, [membersCacheKey, messagesCacheKey, projectId]);
const refreshRooms = useCallback(async () => { const refreshRooms = useCallback(async () => {
if (roomsRef.current.length === 0) { if (roomsRef.current.length === 0) {
@@ -157,7 +203,7 @@ export function useChatRooms(
const persistedRoom = sortedRooms.find((room) => room.id === persistedRoomId) ?? null; const persistedRoom = sortedRooms.find((room) => room.id === persistedRoomId) ?? null;
if (persistedRoom) { if (persistedRoom) {
setActiveRoom(persistedRoom); setActiveRoom(persistedRoom);
void loadRoomData(persistedRoom, true); void loadRoomData(persistedRoom);
} else { } else {
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId); removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
writeCache(activeRoomCacheKey, "", { maxBytes: 500_000 }); writeCache(activeRoomCacheKey, "", { maxBytes: 500_000 });
@@ -174,19 +220,21 @@ export function useChatRooms(
const selectRoom = useCallback((roomId: string | null) => { const selectRoom = useCallback((roomId: string | null) => {
if (!roomId) { if (!roomId) {
activeRoomRef.current = null;
setActiveRoom(null); setActiveRoom(null);
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId); removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
writeCache(activeRoomCacheKey, "", { maxBytes: 500_000 }); writeCache(activeRoomCacheKey, "", { maxBytes: 500_000 });
void loadRoomData(null, true); void loadRoomData(null);
return; return;
} }
const room = roomsRef.current.find((candidate) => candidate.id === roomId) ?? null; const room = roomsRef.current.find((candidate) => candidate.id === roomId) ?? null;
activeRoomRef.current = room;
setActiveRoom(room); setActiveRoom(room);
if (room) { if (room) {
setScopedItem(ACTIVE_ROOM_STORAGE_KEY, room.id, projectId); setScopedItem(ACTIVE_ROOM_STORAGE_KEY, room.id, projectId);
writeCache(activeRoomCacheKey, room.id, { maxBytes: 500_000 }); writeCache(activeRoomCacheKey, room.id, { maxBytes: 500_000 });
void loadRoomData(room, true); void loadRoomData(room);
} }
}, [activeRoomCacheKey, loadRoomData, projectId]); }, [activeRoomCacheKey, loadRoomData, projectId]);
@@ -195,10 +243,11 @@ export function useChatRooms(
const nextRoom = created.room; const nextRoom = created.room;
setRooms((previous) => upsertRoom(previous, nextRoom)); setRooms((previous) => upsertRoom(previous, nextRoom));
activeRoomRef.current = nextRoom;
setActiveRoom(nextRoom); setActiveRoom(nextRoom);
setScopedItem(ACTIVE_ROOM_STORAGE_KEY, nextRoom.id, projectId); setScopedItem(ACTIVE_ROOM_STORAGE_KEY, nextRoom.id, projectId);
writeCache(activeRoomCacheKey, nextRoom.id, { maxBytes: 500_000 }); writeCache(activeRoomCacheKey, nextRoom.id, { maxBytes: 500_000 });
await loadRoomData(nextRoom, true); await loadRoomData(nextRoom);
return nextRoom; return nextRoom;
}, [activeRoomCacheKey, loadRoomData, projectId]); }, [activeRoomCacheKey, loadRoomData, projectId]);
@@ -206,15 +255,19 @@ export function useChatRooms(
const deleteRoomLocal = useCallback(async (roomId: string) => { const deleteRoomLocal = useCallback(async (roomId: string) => {
await deleteChatRoom(roomId, projectId); await deleteChatRoom(roomId, projectId);
setRooms((previous) => previous.filter((room) => room.id !== roomId)); setRooms((previous) => previous.filter((room) => room.id !== roomId));
// Invalidate by writing empty snapshots for deterministic warm-open behavior.
writeCache(messagesCacheKey(roomId), [], { maxBytes: 500_000 });
writeCache(membersCacheKey(roomId), [], { maxBytes: 500_000 });
if (activeRoomRef.current?.id === roomId) { if (activeRoomRef.current?.id === roomId) {
activeRoomRef.current = null;
setActiveRoom(null); setActiveRoom(null);
setActiveRoomMembers([]); setActiveRoomMembers([]);
setMessages([]); setMessages([]);
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId); removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
writeCache(activeRoomCacheKey, "", { maxBytes: 500_000 }); writeCache(activeRoomCacheKey, "", { maxBytes: 500_000 });
} }
}, [activeRoomCacheKey, projectId]); }, [activeRoomCacheKey, membersCacheKey, messagesCacheKey, projectId]);
/** /**
* Sends a room message with optimistic UI. * Sends a room message with optimistic UI.
@@ -230,6 +283,7 @@ export function useChatRooms(
throw new Error("Select a room before sending a message"); throw new Error("Select a room before sending a message");
} }
const timer = startRoomOpenTimer(roomId, { warm: false });
const placeholderAttachments = opts?.files?.map((file) => ({ const placeholderAttachments = opts?.files?.map((file) => ({
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
filename: file.name, filename: file.name,
@@ -241,7 +295,12 @@ export function useChatRooms(
const optimisticMessage = createOptimisticRoomMessage(roomId, content, placeholderAttachments?.length ? placeholderAttachments : opts?.attachments); const optimisticMessage = createOptimisticRoomMessage(roomId, content, placeholderAttachments?.length ? placeholderAttachments : opts?.attachments);
if (activeRoomRef.current?.id === roomId) { if (activeRoomRef.current?.id === roomId) {
setMessages((previous) => [...previous, optimisticMessage]); setMessages((previous) => {
const next = [...previous, optimisticMessage];
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(roomId), next, { maxBytes: 500_000 });
return next;
});
} }
let userMessageDelivered = false; let userMessageDelivered = false;
@@ -272,24 +331,40 @@ export function useChatRooms(
} }
if (activeRoomRef.current?.id === roomId) { if (activeRoomRef.current?.id === roomId) {
setMessages((previous) => previous.map((message) => setMessages((previous) => {
message.id === optimisticMessage.id ? postResult.message : message)); const next = previous.map((message) =>
message.id === optimisticMessage.id ? postResult.message : message);
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(roomId), next, { maxBytes: 500_000 });
return next;
});
} }
const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100, order: "desc" }, projectId); const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100, order: "desc" }, projectId);
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(roomId), latestMessages.messages, { maxBytes: 500_000 });
if (activeRoomRef.current?.id !== roomId) { if (activeRoomRef.current?.id !== roomId) {
return; return;
} }
setMessages(latestMessages.messages); setMessages(latestMessages.messages);
timer.mark("hydrate");
} catch (error) { } catch (error) {
try { try {
const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100, order: "desc" }, projectId); const latestMessages = await fetchChatRoomMessages(roomId, { limit: 100, order: "desc" }, projectId);
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(roomId), latestMessages.messages, { maxBytes: 500_000 });
if (activeRoomRef.current?.id === roomId) { if (activeRoomRef.current?.id === roomId) {
setMessages(latestMessages.messages); setMessages(latestMessages.messages);
timer.mark("hydrate");
} }
} catch { } catch {
if (activeRoomRef.current?.id === roomId) { if (activeRoomRef.current?.id === roomId) {
setMessages((previous) => previous.filter((message) => message.id !== optimisticMessage.id)); setMessages((previous) => {
const next = previous.filter((message) => message.id !== optimisticMessage.id);
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(roomId), next, { maxBytes: 500_000 });
return next;
});
} }
} }
@@ -301,8 +376,10 @@ export function useChatRooms(
} }
throw error; throw error;
} finally {
timer.complete({ source: "send-room-message" });
} }
}, [projectId]); }, [messagesCacheKey, projectId]);
const clearRoom = useCallback(async (roomId: string) => { const clearRoom = useCallback(async (roomId: string) => {
if (!roomId || !roomsRef.current.some((room) => room.id === roomId)) { if (!roomId || !roomsRef.current.some((room) => room.id === roomId)) {
@@ -310,10 +387,12 @@ export function useChatRooms(
} }
await clearChatRoomMessages(roomId, projectId); await clearChatRoomMessages(roomId, projectId);
// Invalidate by writing an empty snapshot for deterministic warm-open behavior.
writeCache(messagesCacheKey(roomId), [], { maxBytes: 500_000 });
if (activeRoomRef.current?.id === roomId) { if (activeRoomRef.current?.id === roomId) {
setMessages([]); setMessages([]);
} }
}, [projectId]); }, [messagesCacheKey, projectId]);
useEffect(() => { useEffect(() => {
void refreshRooms(); void refreshRooms();
@@ -331,6 +410,13 @@ export function useChatRooms(
projectId, projectId,
replayAttempted: false, replayAttempted: false,
}); });
const roomId = activeRoomRef.current?.id;
if (roomId) {
const timer = startRoomOpenTimer(roomId, { warm: true });
timer.mark("sse-reconnect-refresh");
void refreshRooms().finally(() => timer.complete({ source: "sse-reconnect" }));
return;
}
void refreshRooms(); void refreshRooms();
}, },
events: { events: {
@@ -354,7 +440,11 @@ export function useChatRooms(
const payload = parseSsePayload<{ id: string }>(event); const payload = parseSsePayload<{ id: string }>(event);
if (!payload?.id) return; if (!payload?.id) return;
setRooms((previous) => previous.filter((room) => room.id !== payload.id)); setRooms((previous) => previous.filter((room) => room.id !== payload.id));
// Invalidate by writing empty snapshots for deterministic warm-open behavior.
writeCache(messagesCacheKey(payload.id), [], { maxBytes: 500_000 });
writeCache(membersCacheKey(payload.id), [], { maxBytes: 500_000 });
if (activeRoomRef.current?.id === payload.id) { if (activeRoomRef.current?.id === payload.id) {
activeRoomRef.current = null;
setActiveRoom(null); setActiveRoom(null);
setActiveRoomMembers([]); setActiveRoomMembers([]);
setMessages([]); setMessages([]);
@@ -370,14 +460,20 @@ export function useChatRooms(
if (previous.some((member) => member.agentId === payload.agentId)) { if (previous.some((member) => member.agentId === payload.agentId)) {
return previous; return previous;
} }
return [...previous, payload]; const next = [...previous, payload];
writeCache(membersCacheKey(payload.roomId), next, { maxBytes: 500_000 });
return next;
}); });
}, },
"chat:room:member:removed": (event) => { "chat:room:member:removed": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return; if (projectContextVersionRef.current !== contextVersionAtStart) return;
const payload = parseSsePayload<{ roomId: string; agentId: string }>(event); const payload = parseSsePayload<{ roomId: string; agentId: string }>(event);
if (!payload || activeRoomRef.current?.id !== payload.roomId) return; if (!payload || activeRoomRef.current?.id !== payload.roomId) return;
setActiveRoomMembers((previous) => previous.filter((member) => member.agentId !== payload.agentId)); setActiveRoomMembers((previous) => {
const next = previous.filter((member) => member.agentId !== payload.agentId);
writeCache(membersCacheKey(payload.roomId), next, { maxBytes: 500_000 });
return next;
});
}, },
"chat:room:message:added": (event) => { "chat:room:message:added": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return; if (projectContextVersionRef.current !== contextVersionAtStart) return;
@@ -404,30 +500,50 @@ export function useChatRooms(
if (optimisticIndex >= 0) { if (optimisticIndex >= 0) {
const next = [...previous]; const next = [...previous];
next[optimisticIndex] = message; next[optimisticIndex] = message;
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(message.roomId), next, { maxBytes: 500_000 });
return next; return next;
} }
} }
return [...previous, message]; const next = [...previous, message];
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(message.roomId), next, { maxBytes: 500_000 });
return next;
}); });
}, },
"chat:room:message:updated": (event) => { "chat:room:message:updated": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return; if (projectContextVersionRef.current !== contextVersionAtStart) return;
const message = parseSsePayload<ChatRoomMessage>(event); const message = parseSsePayload<ChatRoomMessage>(event);
if (!message || activeRoomRef.current?.id !== message.roomId) return; if (!message || activeRoomRef.current?.id !== message.roomId) return;
setMessages((previous) => previous.map((candidate) => (candidate.id === message.id ? message : candidate))); setMessages((previous) => {
const next = previous.map((candidate) => (candidate.id === message.id ? message : candidate));
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(message.roomId), next, { maxBytes: 500_000 });
return next;
});
}, },
"chat:room:message:deleted": (event) => { "chat:room:message:deleted": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return; if (projectContextVersionRef.current !== contextVersionAtStart) return;
const payload = parseSsePayload<{ id: string }>(event); const payload = parseSsePayload<{ id: string }>(event);
if (!payload?.id) return; if (!payload?.id) return;
setMessages((previous) => previous.filter((message) => message.id !== payload.id)); setMessages((previous) => {
const next = previous.filter((message) => message.id !== payload.id);
const activeRoomId = activeRoomRef.current?.id;
if (activeRoomId) {
// Snapshot mirrors server `order: desc` shape.
writeCache(messagesCacheKey(activeRoomId), next, { maxBytes: 500_000 });
}
return next;
});
}, },
"chat:room:messages:cleared": (event) => { "chat:room:messages:cleared": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return; if (projectContextVersionRef.current !== contextVersionAtStart) return;
const payload = parseSsePayload<{ roomId: string; deletedCount: number }>(event); const payload = parseSsePayload<{ roomId: string; deletedCount: number }>(event);
if (!payload?.roomId) return; if (!payload?.roomId) return;
// Invalidate by writing an empty snapshot for deterministic warm-open behavior.
writeCache(messagesCacheKey(payload.roomId), [], { maxBytes: 500_000 });
if (activeRoomRef.current?.id === payload.roomId) { if (activeRoomRef.current?.id === payload.roomId) {
setMessages([]); setMessages([]);
} }
@@ -440,11 +556,12 @@ export function useChatRooms(
}, },
}, },
}); });
}, [activeRoomCacheKey, projectId, refreshRooms]); }, [activeRoomCacheKey, membersCacheKey, messagesCacheKey, projectId, refreshRooms]);
useEffect(() => { useEffect(() => {
if (!activeRoom) return; if (!activeRoom) return;
if (!rooms.some((room) => room.id === activeRoom.id)) { if (!rooms.some((room) => room.id === activeRoom.id)) {
activeRoomRef.current = null;
setActiveRoom(null); setActiveRoom(null);
setActiveRoomMembers([]); setActiveRoomMembers([]);
setMessages([]); setMessages([]);

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
async function loadDiagnosticsModule() {
return import("../roomOpenDiagnostics");
}
describe("roomOpenDiagnostics", () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("does not emit logs when gate is off", async () => {
vi.stubEnv("DEV", "");
vi.resetModules();
vi.stubGlobal("performance", { now: vi.fn(() => 10) });
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const { startRoomOpenTimer } = await loadDiagnosticsModule();
const timer = startRoomOpenTimer("room-a", { warm: false });
timer.mark("select");
timer.complete();
expect(debugSpy).not.toHaveBeenCalled();
});
it("emits phased timing when localStorage debug gate is on", async () => {
localStorage.setItem("kb-debug-room-open", "1");
let tick = 0;
vi.stubGlobal("performance", {
now: vi.fn(() => {
tick += 25;
return tick;
}),
});
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const { startRoomOpenTimer } = await loadDiagnosticsModule();
const timer = startRoomOpenTimer("room-a", { warm: true });
timer.mark("select");
timer.mark("cache-hit");
timer.mark("messages-fetch");
timer.complete({ fromReconnect: false });
expect(debugSpy).toHaveBeenCalledTimes(1);
expect(debugSpy).toHaveBeenCalledWith(
"[room-open]",
expect.objectContaining({
roomId: "room-a",
warm: true,
totalMs: expect.any(Number),
phases: expect.objectContaining({
select: 25,
"cache-hit": 25,
"messages-fetch": 25,
complete: 25,
}),
fromReconnect: false,
}),
);
});
it("complete is idempotent", async () => {
localStorage.setItem("kb-debug-room-open", "1");
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const { startRoomOpenTimer } = await loadDiagnosticsModule();
const timer = startRoomOpenTimer("room-a");
timer.mark("select");
timer.complete();
timer.mark("hydrate");
timer.complete();
expect(debugSpy).toHaveBeenCalledTimes(1);
});
it("cancel suppresses log emission", async () => {
localStorage.setItem("kb-debug-room-open", "1");
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
const { startRoomOpenTimer } = await loadDiagnosticsModule();
const timer = startRoomOpenTimer("room-a");
timer.mark("select");
timer.cancel();
timer.complete();
expect(debugSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,96 @@
export type RoomOpenPhase =
| "select"
| "cache-hit"
| "members-fetch"
| "messages-fetch"
| "hydrate"
| "sse-reconnect-refresh"
| "complete";
export interface RoomOpenTimer {
mark: (phase: RoomOpenPhase) => void;
complete: (extra?: Record<string, number | string | boolean>) => void;
cancel: () => void;
}
function nowMs(): number {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
}
function isDiagnosticsEnabled(): boolean {
try {
if (typeof import.meta !== "undefined" && import.meta.env?.DEV) {
return true;
}
} catch {
// noop
}
try {
if (typeof localStorage !== "undefined") {
return localStorage.getItem("kb-debug-room-open") === "1";
}
} catch {
// noop
}
return false;
}
export function startRoomOpenTimer(roomId: string, opts?: { warm: boolean }): RoomOpenTimer {
const enabled = isDiagnosticsEnabled();
const warm = opts?.warm === true;
const startedAt = nowMs();
const marks = new Map<RoomOpenPhase, number>();
let finalized = false;
let cancelled = false;
const complete = (extra?: Record<string, number | string | boolean>) => {
if (!enabled || finalized || cancelled) {
finalized = true;
return;
}
finalized = true;
marks.set("complete", nowMs());
const entries = Array.from(marks.entries()).sort((a, b) => a[1] - b[1]);
let previous = startedAt;
const phases: Partial<Record<RoomOpenPhase, number>> = {};
for (const [phase, timestamp] of entries) {
phases[phase] = Math.max(0, timestamp - previous);
previous = timestamp;
}
const totalMs = Math.max(0, nowMs() - startedAt);
try {
console.debug("[room-open]", {
roomId,
warm,
totalMs,
phases,
...(extra ?? {}),
});
} catch {
// noop
}
};
return {
mark(phase) {
if (finalized || cancelled || !enabled || marks.has(phase)) {
return;
}
marks.set(phase, nowMs());
},
complete,
cancel() {
cancelled = true;
finalized = true;
},
};
}

View File

@@ -3,6 +3,7 @@
* *
* Board task hydration uses a dedicated soft bound (`SWR_TASKS_MAX_AGE_MS`) so reloads do not present obviously stale task snapshots. * Board task hydration uses a dedicated soft bound (`SWR_TASKS_MAX_AGE_MS`) so reloads do not present obviously stale task snapshots.
* Chat messages and chat agents maps reuse that short TTL for fast-moving thread state, while models and discovered skills use the default 10-minute window for effectively session-static hydration. * Chat messages and chat agents maps reuse that short TTL for fast-moving thread state, while models and discovered skills use the default 10-minute window for effectively session-static hydration.
* Room message/member hydration uses `SWR_CHAT_ROOM_MAX_AGE_MS` for warm-first room opens while keeping background revalidation mandatory.
* Failed task revalidation clears the per-project tasks envelope to avoid re-hydrating stale data on the next reload. * Failed task revalidation clears the per-project tasks envelope to avoid re-hydrating stale data on the next reload.
* *
* Invalidation contract: * Invalidation contract:
@@ -20,6 +21,8 @@ export const SWR_CACHE_KEYS = {
CHAT_ROOMS: "kb-dashboard-chat-rooms-cache", CHAT_ROOMS: "kb-dashboard-chat-rooms-cache",
CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:", CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:",
CHAT_MESSAGES_PREFIX: "kb-dashboard-chat-messages-cache:", CHAT_MESSAGES_PREFIX: "kb-dashboard-chat-messages-cache:",
CHAT_ROOM_MESSAGES_PREFIX: "kb-dashboard-chat-room-messages-cache:",
CHAT_ROOM_MEMBERS_PREFIX: "kb-dashboard-chat-room-members-cache:",
CHAT_AGENTS_MAP_PREFIX: "kb-dashboard-chat-agents-map-cache:", CHAT_AGENTS_MAP_PREFIX: "kb-dashboard-chat-agents-map-cache:",
MODELS: "kb-dashboard-models-cache", MODELS: "kb-dashboard-models-cache",
DISCOVERED_SKILLS_PREFIX: "kb-dashboard-discovered-skills-cache:", DISCOVERED_SKILLS_PREFIX: "kb-dashboard-discovered-skills-cache:",
@@ -48,6 +51,7 @@ interface CacheEnvelope<T> {
export const SWR_DEFAULT_MAX_AGE_MS = 10 * 60 * 1000; export const SWR_DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
// Board hydration soft bound: keep stale tasks visible briefly while forcing immediate revalidation. // Board hydration soft bound: keep stale tasks visible briefly while forcing immediate revalidation.
export const SWR_TASKS_MAX_AGE_MS = 60_000; export const SWR_TASKS_MAX_AGE_MS = 60_000;
export const SWR_CHAT_ROOM_MAX_AGE_MS = 60_000;
export const SWR_LONG_MAX_AGE_MS = 24 * 60 * 60 * 1000; export const SWR_LONG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function getLocalStorage(): Storage | null { function getLocalStorage(): Storage | null {

View File

@@ -0,0 +1,55 @@
# Room Open Performance (FN-5388)
## Architecture overview
Room open flow in the dashboard:
1. Rooms list hydrates from SWR cache (`CHAT_ROOMS`) and revalidates from `fetchChatRooms`.
2. Active room is resolved from persisted active-room cache.
3. `loadRoomData` does a warm-first read of per-room message/member caches, then background revalidation (`fetchChatRoomMembers` + `fetchChatRoomMessages` in `Promise.all`).
4. SSE continues to update rooms, members, and messages and writes through to cache.
## Per-room SWR cache contract
### Keys
- Messages: `SWR_CACHE_KEYS.CHAT_ROOM_MESSAGES_PREFIX + <projectId|global>:<roomId>`
- Members: `SWR_CACHE_KEYS.CHAT_ROOM_MEMBERS_PREFIX + <projectId|global>:<roomId>`
### TTL
- Room warm-open message TTL: `SWR_CHAT_ROOM_MAX_AGE_MS = 60_000`
- Members read with `SWR_DEFAULT_MAX_AGE_MS`
### Write points
- `loadRoomData` revalidate success writes members + messages.
- SSE write-through:
- `chat:room:message:added|updated|deleted|messages:cleared`
- `chat:room:member:added|removed`
- Post-send transcript refresh writes room-message cache.
### Invalidation points
- `clearRoom` writes empty message snapshot.
- `chat:room:deleted` and `deleteRoom` write empty message/member snapshots.
## Diagnostics
Enable diagnostics by setting:
```js
localStorage.setItem("kb-debug-room-open", "1");
```
`[room-open]` debug payload includes `roomId`, warm flags, `totalMs`, and per-phase deltas (`select`, `cache-hit`, `members-fetch`, `messages-fetch`, `hydrate`, `sse-reconnect-refresh`, `complete`).
## Ordering invariant
Server room-message API is requested with `order: "desc"` and returns newest-first. Client render order remains unchanged and cache snapshots intentionally mirror that `desc` order.
## Out of scope
- Direct-chat (`useChat`) cache/latency changes
- ChatView virtualization
- Scroll restoration changes (FN-5380)
- Generic dashboard resume/reliability instrumentation (FN-5385/FN-5389)
## Follow-up threshold
If `hydrate -> complete` is consistently >150ms on a 100-message room, file a follow-up task for ChatView virtualization (do not implement virtualization in FN-5388).