feat(FN-5202): add SWR caching for chat agents, skills, and models
This merge introduces SWR-style caching across the dashboard to eliminate redundant fetches for models, agents, and skills, significantly refactoring ChatView.tsx (reduced by ~140 lines) by offloading cache coordination to three new dedicated hooks (`useAgentsMapCache`, `useDiscoveredSkillsCache`, ` Fusion-Task-Id: FN-5202
This commit is contained in:
committed by
gsxdsm
parent
99665009f3
commit
cfc70b21bd
@@ -28,10 +28,8 @@ import { useChat, type ChatMessageInfo, type FailureInfo, type ToolCallInfo } fr
|
||||
import { useChatRooms } from "../hooks/useChatRooms";
|
||||
import { useChatUnread } from "../hooks/useChatUnread";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api";
|
||||
import { updateGlobalSettings } from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
@@ -39,6 +37,9 @@ import { AgentAvatar } from "./AgentAvatar";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { CreateRoomModal } from "./CreateRoomModal";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useModelsCache } from "../hooks/useModelsCache";
|
||||
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
|
||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
@@ -515,60 +516,20 @@ interface NewChatDialogProps {
|
||||
|
||||
function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
|
||||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [agentsLoading, setAgentsLoading] = useState(true);
|
||||
const { agents, loading: agentsLoading } = useAgentsMapCache(projectId);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, loading: modelsLoading, refresh } = useModelsCache();
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels);
|
||||
|
||||
// Load agents on mount (project-scoped)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAgentsLoading(true);
|
||||
fetchAgents(undefined, projectId)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setAgents(response);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// Silently fail - show empty list
|
||||
setAgents([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setAgentsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
setFavoriteProviders(cachedFavoriteProviders);
|
||||
}, [cachedFavoriteProviders]);
|
||||
|
||||
// Load models on mount
|
||||
useEffect(() => {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - show empty list
|
||||
setModels([]);
|
||||
setFavoriteProviders([]);
|
||||
setFavoriteModels([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setModelsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
setFavoriteModels(cachedFavoriteModels);
|
||||
}, [cachedFavoriteModels]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
@@ -581,10 +542,11 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
||||
await refresh();
|
||||
} catch {
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
}, [favoriteProviders, favoriteModels, refresh]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const currentFavorites = favoriteModels;
|
||||
@@ -597,10 +559,11 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
||||
await refresh();
|
||||
} catch {
|
||||
setFavoriteModels(currentFavorites);
|
||||
}
|
||||
}, [favoriteModels, favoriteProviders]);
|
||||
}, [favoriteModels, favoriteProviders, refresh]);
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -945,6 +908,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredSessions,
|
||||
agentsMap: chatAgentsMap,
|
||||
} = useChat(projectId, addToast);
|
||||
|
||||
const [showNewDialog, setShowNewDialog] = useState(false);
|
||||
@@ -978,10 +942,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
|
||||
const [createRoomOpen, setCreateRoomOpen] = useState(false);
|
||||
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
|
||||
const [defaultModel, setDefaultModel] = useState<DefaultModelSelection>({ provider: null, modelId: null });
|
||||
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
const { agentsMap: cachedAgentsMap } = useAgentsMapCache(projectId);
|
||||
const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]);
|
||||
const { defaultProvider, defaultModelId } = useModelsCache();
|
||||
const defaultModel = useMemo<DefaultModelSelection>(() => ({ provider: defaultProvider, modelId: defaultModelId }), [defaultModelId, defaultProvider]);
|
||||
const { skills: discoveredSkills, loading: skillsLoading } = useDiscoveredSkillsCache(projectId);
|
||||
const [showSkillMenu, setShowSkillMenu] = useState(false);
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const [highlightedSkillIndex, setHighlightedSkillIndex] = useState(0);
|
||||
@@ -1532,79 +1497,6 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, [roomThreadActive, anchorToBottom, activeSession?.id, chatScope]);
|
||||
|
||||
// Fetch agents on mount for name resolution (project-scoped with stale-request protection)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const currentProjectId = projectId;
|
||||
fetchAgents(undefined, projectId)
|
||||
.then((agents) => {
|
||||
// Ignore response if project changed during fetch
|
||||
if (cancelled || currentProjectId !== projectId) return;
|
||||
const map = new Map<string, Agent>();
|
||||
for (const agent of agents) {
|
||||
map.set(agent.id, agent);
|
||||
}
|
||||
setAgentsMap(map);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - keep empty map
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setDefaultModel({
|
||||
provider: response.defaultProvider ?? null,
|
||||
modelId: response.defaultModelId ?? null,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setDefaultModel({ provider: null, modelId: null });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch discovered skills for slash command autocomplete
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSkillsLoading(true);
|
||||
|
||||
fetchDiscoveredSkills(projectId)
|
||||
.then((skills) => {
|
||||
if (!cancelled) {
|
||||
setDiscoveredSkills(skills);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setDiscoveredSkills([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setSkillsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingAttachmentsRef.current = pendingAttachments;
|
||||
}, [pendingAttachments]);
|
||||
|
||||
@@ -54,6 +54,10 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) {
|
||||
clearCache(SWR_CACHE_KEYS.CHAT_ROOMS);
|
||||
clearCache(SWR_CACHE_KEYS.ACTIVE_CHAT_ROOM_ID);
|
||||
clearCache(SWR_CACHE_KEYS.CHAT_SESSIONS_PREFIX);
|
||||
clearCache(SWR_CACHE_KEYS.CHAT_MESSAGES_PREFIX);
|
||||
clearCache(SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX);
|
||||
clearCache(SWR_CACHE_KEYS.MODELS);
|
||||
clearCache(SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX);
|
||||
clearCache(SWR_CACHE_KEYS.INSIGHTS_PREFIX);
|
||||
clearCache(SWR_CACHE_KEYS.INSIGHT_LATEST_RUN_PREFIX);
|
||||
clearCache(SWR_CACHE_KEYS.RESEARCH_RUNS_PREFIX);
|
||||
|
||||
@@ -120,6 +120,7 @@ describe("resolveSessionProvider", () => {
|
||||
describe("ChatView default model icon", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: [],
|
||||
favoriteProviders: [],
|
||||
|
||||
119
packages/dashboard/app/hooks/__tests__/useAgentsMapCache.test.ts
Normal file
119
packages/dashboard/app/hooks/__tests__/useAgentsMapCache.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
||||
import { useAgentsMapCache } from "../useAgentsMapCache";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { fetchAgents } = await import("../../api");
|
||||
const mockFetchAgents = vi.mocked(fetchAgents);
|
||||
|
||||
describe("useAgentsMapCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", metadata: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates synchronously from cache", async () => {
|
||||
localStorage.setItem(
|
||||
`${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}proj-1`,
|
||||
JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
data: [{ id: "agent-cached", name: "Cached", role: "reviewer", state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", metadata: {} }],
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAgentsMapCache("proj-1"));
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.agentsMap.get("agent-cached")?.name).toBe("Cached");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "proj-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("loads on cache miss and writes through", async () => {
|
||||
const { result } = renderHook(() => useAgentsMapCache("proj-1"));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.agents[0]?.id).toBe("agent-1");
|
||||
});
|
||||
|
||||
const cached = JSON.parse(localStorage.getItem(`${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}proj-1`) ?? "null") as { data: Array<{ id: string }> };
|
||||
expect(cached.data[0]?.id).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("deduplicates concurrent mounts per project", async () => {
|
||||
let resolveFetch: ((agents: Awaited<ReturnType<typeof fetchAgents>>) => void) | undefined;
|
||||
mockFetchAgents.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}));
|
||||
|
||||
const hookA = renderHook(() => useAgentsMapCache("proj-1"));
|
||||
const hookB = renderHook(() => useAgentsMapCache("proj-1"));
|
||||
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch?.([
|
||||
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", metadata: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookA.result.current.loading).toBe(false);
|
||||
expect(hookB.result.current.agentsMap.get("agent-1")?.name).toBe("Alpha");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears cache on empty-cache failure", async () => {
|
||||
const swrCacheModule = await import("../../utils/swrCache");
|
||||
const clearCacheSpy = vi.spyOn(swrCacheModule, "clearCache");
|
||||
mockFetchAgents.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const { result } = renderHook(() => useAgentsMapCache("proj-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(`${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}proj-1`);
|
||||
});
|
||||
|
||||
it("hydrates per-project cache on switch and fetches the new project once", async () => {
|
||||
localStorage.setItem(
|
||||
`${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}p1`,
|
||||
JSON.stringify({ savedAt: Date.now(), data: [{ id: "agent-p1", name: "Project One", role: "executor", state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", metadata: {} }] }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
`${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}p2`,
|
||||
JSON.stringify({ savedAt: Date.now(), data: [{ id: "agent-p2", name: "Project Two", role: "reviewer", state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", metadata: {} }] }),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(({ projectId }: { projectId: string }) => useAgentsMapCache(projectId), {
|
||||
initialProps: { projectId: "p1" },
|
||||
});
|
||||
|
||||
expect(result.current.agentsMap.get("agent-p1")?.name).toBe("Project One");
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
rerender({ projectId: "p2" });
|
||||
|
||||
expect(result.current.agentsMap.get("agent-p2")?.name).toBe("Project Two");
|
||||
expect(result.current.loading).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, "p2");
|
||||
});
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,8 @@ const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
|
||||
};
|
||||
|
||||
describe("useChat", () => {
|
||||
const chatSessionsCacheKey = (projectId: string) => `kb-dashboard-chat-sessions-cache:${projectId}`;
|
||||
const chatSessionsCacheKey = (projectId: string) => `${swrCacheModule.SWR_CACHE_KEYS.CHAT_SESSIONS_PREFIX}${projectId}`;
|
||||
const chatMessagesCacheKey = (projectId: string, sessionId: string) => `${swrCacheModule.SWR_CACHE_KEYS.CHAT_MESSAGES_PREFIX}${projectId}:${sessionId}`;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -424,6 +425,143 @@ describe("useChat", () => {
|
||||
expect(calls[1][1]).toBe("proj-002");
|
||||
});
|
||||
|
||||
it("hydrates restored active-session messages from cache before network resolves", async () => {
|
||||
const projectId = "proj-message-cache-hit";
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
localStorage.setItem(
|
||||
chatSessionsCacheKey(projectId),
|
||||
JSON.stringify({ savedAt: Date.now(), data: [session] }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
chatMessagesCacheKey(projectId, session.id),
|
||||
JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
data: [makeMessage({ id: "msg-cached", sessionId: session.id, role: "assistant", content: "Cached reply" })],
|
||||
}),
|
||||
);
|
||||
mockGetScopedItem.mockReturnValue(session.id);
|
||||
|
||||
let resolveFetch: ((value: { messages: ChatMessage[] }) => void) | undefined;
|
||||
mockFetchChatMessages.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useChat(projectId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe(session.id);
|
||||
});
|
||||
|
||||
expect(result.current.messagesLoading).toBe(false);
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ id: "msg-cached", content: "Cached reply" }),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch?.({ messages: [makeMessage({ id: "msg-fresh", sessionId: session.id, role: "assistant", content: "Fresh reply" })] });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages[0]?.id).toBe("msg-fresh");
|
||||
});
|
||||
});
|
||||
|
||||
it("writes loaded messages through to cache", async () => {
|
||||
const projectId = "proj-message-write-through";
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
makeMessage({ id: "msg-001", sessionId: session.id, role: "user", content: "Hello" }),
|
||||
makeMessage({ id: "msg-002", sessionId: session.id, role: "assistant", content: "Hi" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChat(projectId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession(session.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const raw = localStorage.getItem(chatMessagesCacheKey(projectId, session.id));
|
||||
expect(raw).toBeTruthy();
|
||||
const parsed = JSON.parse(raw ?? "null") as { data: ChatMessage[] };
|
||||
expect(parsed.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading on message cache miss until the fetch resolves", async () => {
|
||||
const projectId = "proj-message-cache-miss";
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
let resolveFetch: ((value: { messages: ChatMessage[] }) => void) | undefined;
|
||||
mockFetchChatMessages.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useChat(projectId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession(session.id);
|
||||
});
|
||||
|
||||
expect(result.current.messagesLoading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch?.({ messages: [makeMessage({ id: "msg-001", sessionId: session.id, role: "assistant", content: "Loaded" })] });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messagesLoading).toBe(false);
|
||||
expect(result.current.messages[0]).toEqual(expect.objectContaining({ id: "msg-001", content: "Loaded" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite the session cache when paginating older messages", async () => {
|
||||
const projectId = "proj-pagination-cache";
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
const newestPage = Array.from({ length: 50 }, (_, index) =>
|
||||
makeMessage({ id: `msg-${index + 1}`, sessionId: session.id, role: "assistant", content: `Message ${index + 1}` }),
|
||||
);
|
||||
const olderPage = [makeMessage({ id: "msg-old", sessionId: session.id, role: "assistant", content: "Older message" })];
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: newestPage })
|
||||
.mockResolvedValueOnce({ messages: olderPage });
|
||||
|
||||
const { result } = renderHook(() => useChat(projectId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession(session.id);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toHaveLength(50);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMoreMessages();
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(localStorage.getItem(chatMessagesCacheKey(projectId, session.id)) ?? "null") as { data: ChatMessage[] };
|
||||
expect(parsed.data).toHaveLength(50);
|
||||
expect(parsed.data[0]?.id).toBe("msg-1");
|
||||
});
|
||||
|
||||
it("selects a session and loads its messages", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
@@ -2279,7 +2417,7 @@ describe("useChat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears active session when it is deleted", async () => {
|
||||
it("clears active session cache when it is deleted", async () => {
|
||||
mockFetchChatSessions.mockResolvedValueOnce({
|
||||
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],
|
||||
});
|
||||
@@ -2299,6 +2437,12 @@ describe("useChat", () => {
|
||||
expect(result.current.activeSession?.id).toBe("session-001");
|
||||
});
|
||||
|
||||
const clearCacheSpy = vi.spyOn(swrCacheModule, "clearCache");
|
||||
localStorage.setItem(
|
||||
chatMessagesCacheKey("proj-123", "session-001"),
|
||||
JSON.stringify({ savedAt: Date.now(), data: [makeMessage({ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Cached" })] }),
|
||||
);
|
||||
|
||||
// Simulate SSE event for the active session
|
||||
act(() => {
|
||||
subscribeHandler["chat:session:deleted"]?.({
|
||||
@@ -2310,6 +2454,8 @@ describe("useChat", () => {
|
||||
expect(result.current.activeSession).toBeNull();
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(chatMessagesCacheKey("proj-123", "session-001"));
|
||||
});
|
||||
|
||||
it("adds message on chat:message:added event for active session", async () => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
||||
import { useDiscoveredSkillsCache } from "../useDiscoveredSkillsCache";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchDiscoveredSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
const { fetchDiscoveredSkills } = await import("../../api");
|
||||
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
|
||||
|
||||
describe("useDiscoveredSkillsCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockFetchDiscoveredSkills.mockResolvedValue([
|
||||
{ name: "agent-browser", enabled: true, source: "skills/browser" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates synchronously from cache", async () => {
|
||||
localStorage.setItem(
|
||||
`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-1`,
|
||||
JSON.stringify({ savedAt: Date.now(), data: [{ name: "cached-skill", enabled: true, source: "cache/source" }] }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDiscoveredSkillsCache("proj-1"));
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.skills[0]?.name).toBe("cached-skill");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDiscoveredSkills).toHaveBeenCalledWith("proj-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("loads on cache miss and writes through", async () => {
|
||||
const { result } = renderHook(() => useDiscoveredSkillsCache("proj-1"));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.skills[0]?.name).toBe("agent-browser");
|
||||
});
|
||||
|
||||
const cached = JSON.parse(localStorage.getItem(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-1`) ?? "null") as { data: Array<{ name: string }> };
|
||||
expect(cached.data[0]?.name).toBe("agent-browser");
|
||||
});
|
||||
|
||||
it("deduplicates concurrent mounts per project", async () => {
|
||||
let resolveFetch: ((skills: Awaited<ReturnType<typeof fetchDiscoveredSkills>>) => void) | undefined;
|
||||
mockFetchDiscoveredSkills.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}));
|
||||
|
||||
const hookA = renderHook(() => useDiscoveredSkillsCache("proj-1"));
|
||||
const hookB = renderHook(() => useDiscoveredSkillsCache("proj-1"));
|
||||
|
||||
expect(mockFetchDiscoveredSkills).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch?.([{ name: "agent-browser", enabled: true, source: "skills/browser" }]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookA.result.current.loading).toBe(false);
|
||||
expect(hookB.result.current.skills[0]?.name).toBe("agent-browser");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears cache on empty-cache failure", async () => {
|
||||
const swrCacheModule = await import("../../utils/swrCache");
|
||||
const clearCacheSpy = vi.spyOn(swrCacheModule, "clearCache");
|
||||
mockFetchDiscoveredSkills.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const { result } = renderHook(() => useDiscoveredSkillsCache("proj-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(`${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}proj-1`);
|
||||
});
|
||||
|
||||
it("refresh forces a new request", async () => {
|
||||
const { result } = renderHook(() => useDiscoveredSkillsCache("proj-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
mockFetchDiscoveredSkills.mockResolvedValueOnce([{ name: "filesystem", enabled: false, source: "skills/fs" }]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(mockFetchDiscoveredSkills).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.skills[0]?.name).toBe("filesystem");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useFavorites } from "../useFavorites";
|
||||
import { useModelsCache } from "../useModelsCache";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -14,6 +15,7 @@ const mockUpdateGlobalSettings = vi.mocked(api.updateGlobalSettings);
|
||||
describe("useFavorites", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: [
|
||||
@@ -44,6 +46,18 @@ describe("useFavorites", () => {
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates fetchModels across useFavorites and useModelsCache mounts", async () => {
|
||||
const favoritesHook = renderHook(() => useFavorites());
|
||||
const modelsHook = renderHook(() => useModelsCache());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(favoritesHook.result.current.favoriteProviders).toEqual(["openai"]);
|
||||
expect(modelsHook.result.current.models).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("optimistically toggles provider favorite and persists settings", async () => {
|
||||
const { result } = renderHook(() => useFavorites());
|
||||
|
||||
|
||||
129
packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts
Normal file
129
packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
||||
import { useModelsCache } from "../useModelsCache";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn(),
|
||||
}));
|
||||
|
||||
const { fetchModels } = await import("../../api");
|
||||
const mockFetchModels = vi.mocked(fetchModels);
|
||||
|
||||
describe("useModelsCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
|
||||
favoriteProviders: ["openai"],
|
||||
favoriteModels: ["gpt-4o"],
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates synchronously from cache", async () => {
|
||||
localStorage.setItem(
|
||||
SWR_CACHE_KEYS.MODELS,
|
||||
JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
data: {
|
||||
models: [{ provider: "anthropic", id: "claude", name: "Claude" }],
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: ["claude"],
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useModelsCache());
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.models[0]?.id).toBe("claude");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("loads on cache miss and writes through", async () => {
|
||||
const { result } = renderHook(() => useModelsCache());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.models[0]?.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
const cached = JSON.parse(localStorage.getItem(SWR_CACHE_KEYS.MODELS) ?? "null") as { data: { models: Array<{ id: string }> } };
|
||||
expect(cached.data.models[0]?.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("deduplicates concurrent mounts", async () => {
|
||||
let resolveFetch: ((value: Awaited<ReturnType<typeof fetchModels>>) => void) | undefined;
|
||||
mockFetchModels.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}));
|
||||
|
||||
const hookA = renderHook(() => useModelsCache());
|
||||
const hookB = renderHook(() => useModelsCache());
|
||||
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch?.({
|
||||
models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
|
||||
favoriteProviders: ["openai"],
|
||||
favoriteModels: ["gpt-4o"],
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookA.result.current.loading).toBe(false);
|
||||
expect(hookB.result.current.loading).toBe(false);
|
||||
expect(hookB.result.current.models[0]?.id).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears cache on failure without cached data", async () => {
|
||||
const swrCacheModule = await import("../../utils/swrCache");
|
||||
const clearCacheSpy = vi.spyOn(swrCacheModule, "clearCache");
|
||||
mockFetchModels.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
const { result } = renderHook(() => useModelsCache());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(SWR_CACHE_KEYS.MODELS);
|
||||
});
|
||||
|
||||
it("refresh forces a new request", async () => {
|
||||
const { result } = renderHook(() => useModelsCache());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
mockFetchModels.mockResolvedValueOnce({
|
||||
models: [{ provider: "anthropic", id: "claude", name: "Claude" }],
|
||||
favoriteProviders: ["anthropic"],
|
||||
favoriteModels: ["claude"],
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.models[0]?.id).toBe("claude");
|
||||
});
|
||||
});
|
||||
105
packages/dashboard/app/hooks/useAgentsMapCache.ts
Normal file
105
packages/dashboard/app/hooks/useAgentsMapCache.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchAgents, type Agent } from "../api";
|
||||
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
||||
|
||||
export interface UseAgentsMapCacheResult {
|
||||
agentsMap: Map<string, Agent>;
|
||||
agents: Agent[];
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const inflightByProject = new Map<string, Promise<Agent[]>>();
|
||||
const listenersByProject = new Map<string, Set<(agents: Agent[]) => void>>();
|
||||
|
||||
function getProjectKey(projectId?: string): string {
|
||||
return projectId ?? "global";
|
||||
}
|
||||
|
||||
function getCacheKey(projectId?: string): string {
|
||||
return `${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}${getProjectKey(projectId)}`;
|
||||
}
|
||||
|
||||
function readCachedAgents(projectId?: string): Agent[] | null {
|
||||
return readCache<Agent[]>(getCacheKey(projectId), { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
|
||||
}
|
||||
|
||||
function notifyListeners(projectKey: string, agents: Agent[]): void {
|
||||
for (const listener of listenersByProject.get(projectKey) ?? []) {
|
||||
listener(agents);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSharedAgents(projectId?: string): Promise<Agent[]> {
|
||||
const projectKey = getProjectKey(projectId);
|
||||
const existing = inflightByProject.get(projectKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchAgents(undefined, projectId).finally(() => {
|
||||
inflightByProject.delete(projectKey);
|
||||
});
|
||||
inflightByProject.set(projectKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useAgentsMapCache(projectId?: string): UseAgentsMapCacheResult {
|
||||
const [agents, setAgents] = useState<Agent[]>(() => readCachedAgents(projectId) ?? []);
|
||||
const [loading, setLoading] = useState(() => readCachedAgents(projectId) === null);
|
||||
const hasCachedStateRef = useRef(readCachedAgents(projectId) !== null);
|
||||
const projectKey = getProjectKey(projectId);
|
||||
|
||||
useEffect(() => {
|
||||
const cachedAgents = readCachedAgents(projectId) ?? [];
|
||||
setAgents(cachedAgents);
|
||||
setLoading(readCachedAgents(projectId) === null);
|
||||
hasCachedStateRef.current = readCachedAgents(projectId) !== null;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners = listenersByProject.get(projectKey) ?? new Set<(agents: Agent[]) => void>();
|
||||
listeners.add(setAgents);
|
||||
listenersByProject.set(projectKey, listeners);
|
||||
return () => {
|
||||
listeners.delete(setAgents);
|
||||
if (listeners.size === 0) {
|
||||
listenersByProject.delete(projectKey);
|
||||
}
|
||||
};
|
||||
}, [projectKey]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const nextAgents = await fetchSharedAgents(projectId);
|
||||
hasCachedStateRef.current = true;
|
||||
writeCache(getCacheKey(projectId), nextAgents, { maxBytes: 500_000 });
|
||||
notifyListeners(projectKey, nextAgents);
|
||||
} catch {
|
||||
if (!hasCachedStateRef.current) {
|
||||
clearCache(getCacheKey(projectId));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, projectKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await load();
|
||||
}, [load]);
|
||||
|
||||
const agentsMap = useMemo(() => {
|
||||
const nextMap = new Map<string, Agent>();
|
||||
for (const agent of agents) {
|
||||
nextMap.set(agent.id, agent);
|
||||
}
|
||||
return nextMap;
|
||||
}, [agents]);
|
||||
|
||||
return { agentsMap, agents, loading, refresh };
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
attachChatStream,
|
||||
streamChatResponse,
|
||||
cancelChatResponse,
|
||||
fetchAgents,
|
||||
type ChatFailureInfo,
|
||||
type ChatSessionListResponse,
|
||||
} from "../api";
|
||||
@@ -41,6 +40,7 @@ import type { ChatMessageInfo, FailureInfo, FallbackInfo, ToolCallInfo } from ".
|
||||
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
||||
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
||||
import { useAgentsMapCache } from "./useAgentsMapCache";
|
||||
|
||||
export interface UseChatReturn {
|
||||
// Session state
|
||||
@@ -246,6 +246,11 @@ export function useChat(
|
||||
(targetProjectId?: string) => (targetProjectId ? `${SWR_CACHE_KEYS.CHAT_SESSIONS_PREFIX}${targetProjectId}` : null),
|
||||
[],
|
||||
);
|
||||
const getChatMessagesCacheKey = useCallback(
|
||||
(targetProjectId?: string, sessionId?: string | null) =>
|
||||
targetProjectId && sessionId ? `${SWR_CACHE_KEYS.CHAT_MESSAGES_PREFIX}${targetProjectId}:${sessionId}` : null,
|
||||
[],
|
||||
);
|
||||
|
||||
const readCachedSessions = useCallback(
|
||||
(targetProjectId?: string) => {
|
||||
@@ -280,7 +285,7 @@ export function useChat(
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
||||
|
||||
// Agent name resolution map
|
||||
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
|
||||
const { agentsMap } = useAgentsMapCache(projectId);
|
||||
|
||||
// Stream connection ref for cleanup
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
@@ -295,9 +300,11 @@ export function useChat(
|
||||
// Refs for SSE event handlers to access current state
|
||||
const sessionsRef = useRef(sessions);
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
const messagesRef = useRef(messages);
|
||||
const isStreamingRef = useRef(isStreaming);
|
||||
sessionsRef.current = sessions;
|
||||
activeSessionRef.current = activeSession;
|
||||
messagesRef.current = messages;
|
||||
isStreamingRef.current = isStreaming;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -320,24 +327,6 @@ export function useChat(
|
||||
projectContextVersionRef.current++;
|
||||
}
|
||||
|
||||
// Fetch agents on mount for name resolution (project-scoped with stale-request protection)
|
||||
useEffect(() => {
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
fetchAgents(undefined, projectId)
|
||||
.then((agents) => {
|
||||
// Ignore response if project changed during fetch
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) return;
|
||||
const map = new Map<string, Agent>();
|
||||
for (const agent of agents) {
|
||||
map.set(agent.id, agent);
|
||||
}
|
||||
setAgentsMap(map);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - keep empty map
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch sessions
|
||||
const refreshSessions = useCallback(async () => {
|
||||
if (sessionsRef.current.length === 0) {
|
||||
@@ -411,27 +400,72 @@ export function useChat(
|
||||
hasRestoredActiveSessionRef.current = true;
|
||||
}, [sessionsLoading, sessions, projectId]);
|
||||
|
||||
const readCachedMessages = useCallback(
|
||||
(targetProjectId?: string, sessionId?: string | null) => {
|
||||
const cacheKey = getChatMessagesCacheKey(targetProjectId, sessionId);
|
||||
if (!cacheKey) {
|
||||
return [] as ChatMessageInfo[];
|
||||
}
|
||||
|
||||
return readCache<ChatMessageInfo[]>(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS }) ?? [];
|
||||
},
|
||||
[getChatMessagesCacheKey],
|
||||
);
|
||||
|
||||
const hydrateMessagesFromCache = useCallback(
|
||||
(sessionId?: string | null) => {
|
||||
const cachedMessages = readCachedMessages(projectId, sessionId);
|
||||
if (cachedMessages.length > 0) {
|
||||
setMessages(cachedMessages);
|
||||
setMessagesLoading(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
setMessages([]);
|
||||
return false;
|
||||
},
|
||||
[projectId, readCachedMessages],
|
||||
);
|
||||
|
||||
// Load messages when active session changes
|
||||
const loadMessages = useCallback(
|
||||
async (sessionId: string, opts?: { offset?: number }) => {
|
||||
setMessagesLoading(true);
|
||||
const isPaginationRequest = typeof opts?.offset === "number" && opts.offset > 0;
|
||||
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
||||
const cachedMessages = !isPaginationRequest ? readCachedMessages(projectId, sessionId) : [];
|
||||
const hasCachedMessages = cachedMessages.length > 0;
|
||||
|
||||
if (!isPaginationRequest && hasCachedMessages) {
|
||||
setMessages(cachedMessages);
|
||||
setMessagesLoading(false);
|
||||
} else {
|
||||
setMessagesLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchChatMessages(sessionId, { limit: 50, ...opts }, projectId);
|
||||
const mappedMessages = data.messages.map(mapChatMessageToInfo);
|
||||
if (opts?.offset && opts.offset > 0) {
|
||||
if (isPaginationRequest) {
|
||||
// Prepend older messages
|
||||
setMessages((prev) => [...mappedMessages, ...prev]);
|
||||
} else {
|
||||
setMessages(mappedMessages);
|
||||
if (cacheKey) {
|
||||
writeCache(cacheKey, mappedMessages, { maxBytes: 500_000 });
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(data.messages.length >= 50);
|
||||
} catch {
|
||||
if (!isPaginationRequest && messagesRef.current.length === 0 && hasCachedMessages) {
|
||||
setMessages(cachedMessages);
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
// Silently fail
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
[getChatMessagesCacheKey, projectId, readCachedMessages],
|
||||
);
|
||||
|
||||
const resetTransientComposerState = useCallback(() => {
|
||||
@@ -582,6 +616,7 @@ export function useChat(
|
||||
|
||||
// Load messages for this session
|
||||
if (id) {
|
||||
hydrateMessagesFromCache(id);
|
||||
loadMessages(id);
|
||||
} else {
|
||||
setMessages([]);
|
||||
@@ -602,7 +637,7 @@ export function useChat(
|
||||
removeScopedItem(ACTIVE_SESSION_STORAGE_KEY, projectId);
|
||||
}
|
||||
},
|
||||
[attachIfGenerating, sessions, loadMessages, projectId, resetTransientComposerState],
|
||||
[attachIfGenerating, hydrateMessagesFromCache, sessions, loadMessages, projectId, resetTransientComposerState],
|
||||
);
|
||||
|
||||
// Update the ref to point to the actual selectSession function
|
||||
@@ -637,7 +672,6 @@ export function useChat(
|
||||
|
||||
resetTransientComposerState();
|
||||
selectSession(newSession.id, newSession);
|
||||
setMessages([]);
|
||||
|
||||
return newSession;
|
||||
},
|
||||
@@ -673,6 +707,10 @@ export function useChat(
|
||||
}
|
||||
|
||||
await deleteChatSession(id, projectId);
|
||||
const cacheKey = getChatMessagesCacheKey(projectId, id);
|
||||
if (cacheKey) {
|
||||
clearCache(cacheKey);
|
||||
}
|
||||
// Remove from sessions list
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
// If it was the active session, clear it
|
||||
@@ -681,7 +719,7 @@ export function useChat(
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[activeSession, projectId],
|
||||
[activeSession, getChatMessagesCacheKey, projectId],
|
||||
);
|
||||
|
||||
// Load more messages (pagination)
|
||||
@@ -1055,6 +1093,10 @@ export function useChat(
|
||||
if (isStale()) return;
|
||||
const { id: sessionId }: { id: string } = JSON.parse(e.data);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
||||
if (cacheKey) {
|
||||
clearCache(cacheKey);
|
||||
}
|
||||
// If this was the active session, clear it
|
||||
if (activeSessionRef.current?.id === sessionId) {
|
||||
setActiveSession(null);
|
||||
@@ -1140,7 +1182,7 @@ export function useChat(
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [attachIfGenerating, projectId, flushPendingMessage]);
|
||||
}, [attachIfGenerating, getChatMessagesCacheKey, projectId, flushPendingMessage]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
||||
96
packages/dashboard/app/hooks/useDiscoveredSkillsCache.ts
Normal file
96
packages/dashboard/app/hooks/useDiscoveredSkillsCache.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchDiscoveredSkills, type DiscoveredSkill } from "../api";
|
||||
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
||||
|
||||
export interface UseDiscoveredSkillsCacheResult {
|
||||
skills: DiscoveredSkill[];
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const inflightByProject = new Map<string, Promise<DiscoveredSkill[]>>();
|
||||
const listenersByProject = new Map<string, Set<(skills: DiscoveredSkill[]) => void>>();
|
||||
|
||||
function getProjectKey(projectId?: string): string {
|
||||
return projectId ?? "global";
|
||||
}
|
||||
|
||||
function getCacheKey(projectId?: string): string {
|
||||
return `${SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX}${getProjectKey(projectId)}`;
|
||||
}
|
||||
|
||||
function readCachedSkills(projectId?: string): DiscoveredSkill[] | null {
|
||||
return readCache<DiscoveredSkill[]>(getCacheKey(projectId), { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
||||
}
|
||||
|
||||
function notifyListeners(projectKey: string, skills: DiscoveredSkill[]): void {
|
||||
for (const listener of listenersByProject.get(projectKey) ?? []) {
|
||||
listener(skills);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSharedSkills(projectId?: string): Promise<DiscoveredSkill[]> {
|
||||
const projectKey = getProjectKey(projectId);
|
||||
const existing = inflightByProject.get(projectKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = fetchDiscoveredSkills(projectId).finally(() => {
|
||||
inflightByProject.delete(projectKey);
|
||||
});
|
||||
inflightByProject.set(projectKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useDiscoveredSkillsCache(projectId?: string): UseDiscoveredSkillsCacheResult {
|
||||
const [skills, setSkills] = useState<DiscoveredSkill[]>(() => readCachedSkills(projectId) ?? []);
|
||||
const [loading, setLoading] = useState(() => readCachedSkills(projectId) === null);
|
||||
const hasCachedStateRef = useRef(readCachedSkills(projectId) !== null);
|
||||
const projectKey = getProjectKey(projectId);
|
||||
|
||||
useEffect(() => {
|
||||
const cachedSkills = readCachedSkills(projectId) ?? [];
|
||||
setSkills(cachedSkills);
|
||||
setLoading(readCachedSkills(projectId) === null);
|
||||
hasCachedStateRef.current = readCachedSkills(projectId) !== null;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners = listenersByProject.get(projectKey) ?? new Set<(skills: DiscoveredSkill[]) => void>();
|
||||
listeners.add(setSkills);
|
||||
listenersByProject.set(projectKey, listeners);
|
||||
return () => {
|
||||
listeners.delete(setSkills);
|
||||
if (listeners.size === 0) {
|
||||
listenersByProject.delete(projectKey);
|
||||
}
|
||||
};
|
||||
}, [projectKey]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const nextSkills = await fetchSharedSkills(projectId);
|
||||
hasCachedStateRef.current = true;
|
||||
writeCache(getCacheKey(projectId), nextSkills, { maxBytes: 500_000 });
|
||||
notifyListeners(projectKey, nextSkills);
|
||||
} catch {
|
||||
if (!hasCachedStateRef.current) {
|
||||
clearCache(getCacheKey(projectId));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, projectKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await load();
|
||||
}, [load]);
|
||||
|
||||
return { skills, loading, refresh };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
||||
import { updateGlobalSettings, type ModelInfo } from "../api";
|
||||
import { useModelsCache } from "./useModelsCache";
|
||||
|
||||
/**
|
||||
* Favorite model/provider state and actions consumed by the dashboard App shell.
|
||||
@@ -16,25 +17,26 @@ export interface UseFavoritesResult {
|
||||
* Loads model catalog + favorites and exposes optimistic favorite toggles.
|
||||
*/
|
||||
export function useFavorites(): UseFavoritesResult {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, refresh } = useModelsCache();
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>(models);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels);
|
||||
const favoriteProvidersRef = useRef<string[]>(favoriteProviders);
|
||||
const favoriteModelsRef = useRef<string[]>(favoriteModels);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
favoriteProvidersRef.current = response.favoriteProviders;
|
||||
favoriteModelsRef.current = response.favoriteModels;
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
setFavoriteModels(response.favoriteModels);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep defaults on fetch failure.
|
||||
});
|
||||
}, []);
|
||||
setAvailableModels(models);
|
||||
}, [models]);
|
||||
|
||||
useEffect(() => {
|
||||
favoriteProvidersRef.current = cachedFavoriteProviders;
|
||||
setFavoriteProviders(cachedFavoriteProviders);
|
||||
}, [cachedFavoriteProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
favoriteModelsRef.current = cachedFavoriteModels;
|
||||
setFavoriteModels(cachedFavoriteModels);
|
||||
}, [cachedFavoriteModels]);
|
||||
|
||||
useEffect(() => {
|
||||
favoriteProvidersRef.current = favoriteProviders;
|
||||
@@ -59,12 +61,13 @@ export function useFavorites(): UseFavoritesResult {
|
||||
favoriteProviders: nextFavorites,
|
||||
favoriteModels: favoriteModelsRef.current,
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
favoriteProvidersRef.current = previousFavorites;
|
||||
setFavoriteProviders(() => previousFavorites);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
}, [refresh]);
|
||||
|
||||
const toggleFavoriteModel = useCallback(async (modelId: string) => {
|
||||
const previousFavorites = favoriteModelsRef.current;
|
||||
@@ -81,12 +84,13 @@ export function useFavorites(): UseFavoritesResult {
|
||||
favoriteProviders: favoriteProvidersRef.current,
|
||||
favoriteModels: nextFavorites,
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
favoriteModelsRef.current = previousFavorites;
|
||||
setFavoriteModels(() => previousFavorites);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
availableModels,
|
||||
|
||||
106
packages/dashboard/app/hooks/useModelsCache.ts
Normal file
106
packages/dashboard/app/hooks/useModelsCache.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchModels, type ModelInfo, type ModelsResponse } from "../api";
|
||||
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
||||
|
||||
interface ModelsCacheState {
|
||||
models: ModelInfo[];
|
||||
favoriteProviders: string[];
|
||||
favoriteModels: string[];
|
||||
defaultProvider: string | null;
|
||||
defaultModelId: string | null;
|
||||
}
|
||||
|
||||
export interface UseModelsCacheResult extends ModelsCacheState {
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_MODELS_STATE: ModelsCacheState = {
|
||||
models: [],
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
defaultProvider: null,
|
||||
defaultModelId: null,
|
||||
};
|
||||
|
||||
let inflight: Promise<ModelsResponse> | null = null;
|
||||
const listeners = new Set<(state: ModelsCacheState) => void>();
|
||||
|
||||
function toModelsCacheState(response: ModelsResponse | null | undefined): ModelsCacheState {
|
||||
if (!response) {
|
||||
return EMPTY_MODELS_STATE;
|
||||
}
|
||||
|
||||
return {
|
||||
models: response.models ?? [],
|
||||
favoriteProviders: response.favoriteProviders ?? [],
|
||||
favoriteModels: response.favoriteModels ?? [],
|
||||
defaultProvider: response.defaultProvider ?? null,
|
||||
defaultModelId: response.defaultModelId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function readCachedModelsState(): ModelsCacheState | null {
|
||||
const cached = readCache<ModelsResponse>(SWR_CACHE_KEYS.MODELS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
||||
return cached ? toModelsCacheState(cached) : null;
|
||||
}
|
||||
|
||||
function notifyListeners(state: ModelsCacheState): void {
|
||||
for (const listener of listeners) {
|
||||
listener(state);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchModelsShared(): Promise<ModelsResponse> {
|
||||
if (!inflight) {
|
||||
inflight = fetchModels().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function useModelsCache(): UseModelsCacheResult {
|
||||
const cachedState = readCachedModelsState();
|
||||
const [state, setState] = useState<ModelsCacheState>(() => cachedState ?? EMPTY_MODELS_STATE);
|
||||
const [loading, setLoading] = useState(() => cachedState === null);
|
||||
const hasCachedStateRef = useRef(cachedState !== null);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setState);
|
||||
return () => {
|
||||
listeners.delete(setState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchModelsShared();
|
||||
const nextState = toModelsCacheState(response);
|
||||
hasCachedStateRef.current = true;
|
||||
writeCache(SWR_CACHE_KEYS.MODELS, response, { maxBytes: 500_000 });
|
||||
notifyListeners(nextState);
|
||||
} catch {
|
||||
if (!hasCachedStateRef.current) {
|
||||
clearCache(SWR_CACHE_KEYS.MODELS);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await load();
|
||||
}, [load]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
loading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -121,6 +121,10 @@ describe("swrCache", () => {
|
||||
expect(SWR_CACHE_KEYS.MISSIONS_PREFIX).toBe("kb-dashboard-missions-cache:");
|
||||
expect(SWR_CACHE_KEYS.MISSIONS_SELECTED_ID_PREFIX).toBe("kb-dashboard-mission-selected-cache:");
|
||||
expect(SWR_CACHE_KEYS.CHAT_SESSIONS_PREFIX).toBe("kb-dashboard-chat-sessions-cache:");
|
||||
expect(SWR_CACHE_KEYS.CHAT_MESSAGES_PREFIX).toBe("kb-dashboard-chat-messages-cache:");
|
||||
expect(SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX).toBe("kb-dashboard-chat-agents-map-cache:");
|
||||
expect(SWR_CACHE_KEYS.MODELS).toBe("kb-dashboard-models-cache");
|
||||
expect(SWR_CACHE_KEYS.DISCOVERED_SKILLS_PREFIX).toBe("kb-dashboard-discovered-skills-cache:");
|
||||
expect(SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX).toBe("kb-dashboard-mailbox-inbox-cache:");
|
||||
expect(SWR_CACHE_KEYS.MAILBOX_OUTBOX_PREFIX).toBe("kb-dashboard-mailbox-outbox-cache:");
|
||||
expect(SWR_CACHE_KEYS.MAILBOX_UNREAD_COUNT_PREFIX).toBe("kb-dashboard-mailbox-unread-cache:");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Lightweight stale-while-revalidate cache helpers for dashboard reload hydration.
|
||||
*
|
||||
* 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.
|
||||
* Failed task revalidation clears the per-project tasks envelope to avoid re-hydrating stale data on the next reload.
|
||||
*
|
||||
* Invalidation contract:
|
||||
@@ -18,6 +19,10 @@ export const SWR_CACHE_KEYS = {
|
||||
TODO_LISTS_PREFIX: "kb-dashboard-todo-lists-cache:",
|
||||
CHAT_ROOMS: "kb-dashboard-chat-rooms-cache",
|
||||
CHAT_SESSIONS_PREFIX: "kb-dashboard-chat-sessions-cache:",
|
||||
CHAT_MESSAGES_PREFIX: "kb-dashboard-chat-messages-cache:",
|
||||
CHAT_AGENTS_MAP_PREFIX: "kb-dashboard-chat-agents-map-cache:",
|
||||
MODELS: "kb-dashboard-models-cache",
|
||||
DISCOVERED_SKILLS_PREFIX: "kb-dashboard-discovered-skills-cache:",
|
||||
ACTIVE_CHAT_ROOM_ID: "kb-dashboard-active-chat-room-cache",
|
||||
INSIGHTS_PREFIX: "kb-dashboard-insights-cache:",
|
||||
INSIGHT_LATEST_RUN_PREFIX: "kb-dashboard-insight-latest-run-cache:",
|
||||
|
||||
Reference in New Issue
Block a user