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:
Fusion (runfusion.ai)
2026-05-20 03:12:14 -07:00
committed by gsxdsm
parent 99665009f3
commit cfc70b21bd
15 changed files with 947 additions and 177 deletions

View File

@@ -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]);

View File

@@ -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);

View File

@@ -120,6 +120,7 @@ describe("resolveSessionProvider", () => {
describe("ChatView default model icon", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchModels.mockResolvedValue({
models: [],
favoriteProviders: [],

View 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);
});
});

View File

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

View File

@@ -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");
});
});

View File

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

View 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");
});
});

View 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 };
}

View File

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

View 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 };
}

View File

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

View 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,
};
}

View File

@@ -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:");

View File

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