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:
committed by
gsxdsm
parent
b936ab9bb5
commit
e96cb09982
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
packages/dashboard/app/utils/roomOpenDiagnostics.ts
Normal file
96
packages/dashboard/app/utils/roomOpenDiagnostics.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
55
packages/dashboard/docs/room-open-performance.md
Normal file
55
packages/dashboard/docs/room-open-performance.md
Normal 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).
|
||||||
Reference in New Issue
Block a user