diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 576e5246..e9fb3d09 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -70,6 +70,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; +import { Skeleton } from "@/components/ui/skeleton"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; @@ -380,6 +381,8 @@ function InstructionsTab({ setSaving(true); try { await onSave(value); + } catch { + // toast handled by parent } finally { setSaving(false); } @@ -446,6 +449,8 @@ function SkillsTab({ const newIds = [...agent.skills.map((s) => s.id), skillId]; await api.setAgentSkills(agent.id, { skill_ids: newIds }); await refreshAgents(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to add skill"); } finally { setSaving(false); setShowPicker(false); @@ -458,6 +463,8 @@ function SkillsTab({ const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id); await api.setAgentSkills(agent.id, { skill_ids: newIds }); await refreshAgents(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to remove skill"); } finally { setSaving(false); } @@ -701,6 +708,8 @@ function ToolsTab({ setSaving(true); try { await onSave(tools); + } catch { + // toast handled by parent } finally { setSaving(false); } @@ -845,6 +854,8 @@ function TriggersTab({ setSaving(true); try { await onSave(triggers); + } catch { + // toast handled by parent } finally { setSaving(false); } @@ -1050,8 +1061,17 @@ function TasksTab({ agent }: { agent: Agent }) { if (loading) { return ( -
- Loading tasks... +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))}
); } @@ -1522,25 +1542,68 @@ export default function AgentsPage() { }; const handleUpdate = async (id: string, data: Record) => { - await api.updateAgent(id, data as UpdateAgentRequest); - await refreshAgents(); + try { + await api.updateAgent(id, data as UpdateAgentRequest); + await refreshAgents(); + toast.success("Agent updated"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to update agent"); + throw e; + } }; const handleDelete = async (id: string) => { - await api.deleteAgent(id); - if (selectedId === id) { - const remaining = agents.filter((a) => a.id !== id); - setSelectedId(remaining[0]?.id ?? ""); + try { + await api.deleteAgent(id); + if (selectedId === id) { + const remaining = agents.filter((a) => a.id !== id); + setSelectedId(remaining[0]?.id ?? ""); + } + await refreshAgents(); + toast.success("Agent deleted"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to delete agent"); } - await refreshAgents(); }; const selected = agents.find((a) => a.id === selectedId) ?? null; if (isLoading) { return ( -
- Loading... +
+ {/* List skeleton */} +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ {/* Detail skeleton */} +
+
+ +
+ + +
+
+
+ + + +
+
); } diff --git a/apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx b/apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx index 8a55017a..d26d4c11 100644 --- a/apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/tokens-tab.tsx @@ -22,6 +22,17 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Skeleton } from "@/components/ui/skeleton"; import { toast } from "sonner"; import { api } from "@/shared/api"; @@ -33,13 +44,17 @@ export function TokensTab() { const [newToken, setNewToken] = useState(null); const [tokenCopied, setTokenCopied] = useState(false); const [tokenRevoking, setTokenRevoking] = useState(null); + const [revokeConfirmId, setRevokeConfirmId] = useState(null); + const [tokensLoading, setTokensLoading] = useState(true); const loadTokens = useCallback(async () => { try { const list = await api.listPersonalAccessTokens(); setTokens(list); - } catch { - // ignore — tokens section simply stays empty + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to load tokens"); + } finally { + setTokensLoading(false); } }, []); @@ -117,7 +132,21 @@ export function TokensTab() { - {tokens.length > 0 && ( + {tokensLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + +
+ + +
+ +
+
+ ))} +
+ ) : tokens.length > 0 && (
{tokens.map((t) => ( @@ -135,7 +164,7 @@ export function TokensTab() {
)}
@@ -234,6 +287,7 @@ function CommentCard({ const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; const isTemp = entry.id.startsWith("temp-"); + const [confirmDelete, setConfirmDelete] = useState(false); const startEdit = () => { cancelledRef.current = false; @@ -347,7 +401,7 @@ function CommentCard({ Edit - onDelete(entry.id)} variant="destructive"> + setConfirmDelete(true)} variant="destructive"> Delete @@ -355,6 +409,12 @@ function CommentCard({ )} + onDelete(entry.id)} + hasReplies + />
)} diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index dabf506f..3c573d61 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -19,6 +19,7 @@ import { X, } from "lucide-react"; import { toast } from "sonner"; +import { Skeleton } from "@/components/ui/skeleton"; import { AlertDialog, AlertDialogAction, @@ -214,23 +215,26 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo .then((iss) => { useIssueStore.getState().addIssue(iss); }) - .catch(console.error) + .catch((e) => { + console.error(e); + toast.error("Failed to load issue"); + }) .finally(() => setIssueLoading(false)); }, [id, !!issue]); // Custom hooks — encapsulate timeline, reactions, subscribers const { - timeline, submitting, submitComment, submitReply, + timeline, loading: timelineLoading, submitting, submitComment, submitReply, editComment, deleteComment, toggleReaction: handleToggleReaction, } = useIssueTimeline(id, user?.id); const { - reactions: issueReactions, + reactions: issueReactions, loading: reactionsLoading, toggleReaction: handleToggleIssueReaction, } = useIssueReactions(id, user?.id); const { - subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber, + subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber, } = useIssueSubscribers(id, user?.id); const loading = issueLoading; @@ -305,8 +309,51 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo if (loading) { return ( -
- Loading... +
+ {/* Header skeleton */} +
+ + + +
+
+ {/* Content skeleton */} +
+ +
+ + + +
+ +
+ +
+ +
+ + +
+
+
+
+ {/* Sidebar skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} + + {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+
); } @@ -606,11 +653,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo />
- + {reactionsLoading ? ( +
+ + +
+ ) : ( + + )} Activity
+ {subscribersLoading ? ( +
+ +
+ + +
+
+ ) : (<>
@@ -722,7 +786,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Timeline entries */}
- {(() => { + {timelineLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : (() => { const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id); const repliesByParent = new Map(); for (const e of timeline) { @@ -773,9 +849,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo if (group.type === "comment") { const entry = group.entries[0]!; return ( -
+
{ useIssueStore.getState().setIssues(res.issues); - }); + }).catch(console.error); }); }, [] @@ -131,19 +131,27 @@ export function IssuesPage() { {/* Content: scrollable */} -
- {viewMode === "board" ? ( - - ) : ( - - )} -
+ {scopedIssues.length === 0 ? ( +
+ +

No issues yet

+

Create an issue to get started.

+
+ ) : ( +
+ {viewMode === "board" ? ( + + ) : ( + + )} +
+ )} {viewMode === "list" && }
diff --git a/apps/web/features/issues/hooks/use-issue-reactions.ts b/apps/web/features/issues/hooks/use-issue-reactions.ts index dd152763..3c824933 100644 --- a/apps/web/features/issues/hooks/use-issue-reactions.ts +++ b/apps/web/features/issues/hooks/use-issue-reactions.ts @@ -7,8 +7,8 @@ import type { IssueReactionRemovedPayload, } from "@/shared/types"; import { api } from "@/shared/api"; -import { useWSEvent, useWSReconnect } from "@/features/realtime"; import { toast } from "sonner"; +import { useWSEvent, useWSReconnect } from "@/features/realtime"; export function useIssueReactions(issueId: string, userId?: string) { const [reactions, setReactions] = useState([]); @@ -21,7 +21,10 @@ export function useIssueReactions(issueId: string, userId?: string) { api .getIssue(issueId) .then((iss) => setReactions(iss.reactions ?? [])) - .catch(console.error) + .catch((e) => { + console.error(e); + toast.error("Failed to load reactions"); + }) .finally(() => setLoading(false)); }, [issueId]); diff --git a/apps/web/features/issues/hooks/use-issue-subscribers.ts b/apps/web/features/issues/hooks/use-issue-subscribers.ts index 5675f47a..7c440900 100644 --- a/apps/web/features/issues/hooks/use-issue-subscribers.ts +++ b/apps/web/features/issues/hooks/use-issue-subscribers.ts @@ -7,8 +7,8 @@ import type { SubscriberRemovedPayload, } from "@/shared/types"; import { api } from "@/shared/api"; -import { useWSEvent, useWSReconnect } from "@/features/realtime"; import { toast } from "sonner"; +import { useWSEvent, useWSReconnect } from "@/features/realtime"; export function useIssueSubscribers(issueId: string, userId?: string) { const [subscribers, setSubscribers] = useState([]); @@ -21,7 +21,10 @@ export function useIssueSubscribers(issueId: string, userId?: string) { api .listIssueSubscribers(issueId) .then((subs) => setSubscribers(subs)) - .catch(console.error) + .catch((e) => { + console.error(e); + toast.error("Failed to load subscribers"); + }) .finally(() => setLoading(false)); }, [issueId]); diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index ab2eda6a..294c5bfb 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -41,7 +41,10 @@ export function useIssueTimeline(issueId: string, userId?: string) { api .listTimeline(issueId) .then((entries) => setTimeline(entries)) - .catch(console.error) + .catch((e) => { + console.error(e); + toast.error("Failed to load activity"); + }) .finally(() => setLoading(false)); }, [issueId]); diff --git a/apps/web/features/issues/store.ts b/apps/web/features/issues/store.ts index 32915ea2..1e47b7d7 100644 --- a/apps/web/features/issues/store.ts +++ b/apps/web/features/issues/store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import type { Issue } from "@/shared/types"; +import { toast } from "sonner"; import { api } from "@/shared/api"; import { createLogger } from "@/shared/logger"; @@ -34,6 +35,7 @@ export const useIssueStore = create((set, get) => ({ set({ issues: res.issues, loading: false }); } catch (err) { logger.error("fetch failed", err); + toast.error("Failed to load issues"); if (isInitialLoad) set({ loading: false }); } }, diff --git a/apps/web/features/my-issues/components/my-issues-page.tsx b/apps/web/features/my-issues/components/my-issues-page.tsx index d8bcf855..ac7b13ba 100644 --- a/apps/web/features/my-issues/components/my-issues-page.tsx +++ b/apps/web/features/my-issues/components/my-issues-page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo } from "react"; import { useStore } from "zustand"; import { toast } from "sonner"; -import { ChevronRight } from "lucide-react"; +import { ChevronRight, ListTodo } from "lucide-react"; import type { IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuthStore } from "@/features/auth"; @@ -124,7 +124,7 @@ export function MyIssuesPage() { toast.error("Failed to move issue"); api.listIssues({ limit: 200 }).then((res) => { useIssueStore.getState().setIssues(res.issues); - }); + }).catch(console.error); }); }, [], @@ -171,19 +171,27 @@ export function MyIssuesPage() { {/* Content: scrollable */} -
- {viewMode === "board" ? ( - - ) : ( - - )} -
+ {myIssues.length === 0 ? ( +
+ +

No issues assigned to you

+

Issues you create or are assigned to will appear here.

+
+ ) : ( +
+ {viewMode === "board" ? ( + + ) : ( + + )} +
+ )} {viewMode === "list" && }
diff --git a/apps/web/features/realtime/use-realtime-sync.ts b/apps/web/features/realtime/use-realtime-sync.ts index 74ba9a71..4d37efa9 100644 --- a/apps/web/features/realtime/use-realtime-sync.ts +++ b/apps/web/features/realtime/use-realtime-sync.ts @@ -145,8 +145,8 @@ export function useRealtimeSync(ws: WSClient | null) { useWorkspaceStore.getState().refreshMembers(), useWorkspaceStore.getState().refreshSkills(), ]); - } catch { - // Silently fail; next reconnect will retry + } catch (e) { + logger.error("reconnect refetch failed", e); } }); diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index f1f83324..7a87ee21 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -8,6 +8,7 @@ import { ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; +import { Skeleton } from "@/components/ui/skeleton"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { useWSEvent } from "@/features/realtime"; @@ -44,8 +45,36 @@ export default function RuntimesPage() { if (isLoading || fetching) { return ( -
- Loading... +
+ {/* List skeleton */} +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ {/* Detail skeleton */} +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
); } diff --git a/apps/web/features/runtimes/components/usage-section.tsx b/apps/web/features/runtimes/components/usage-section.tsx index b8a26c34..e5262c67 100644 --- a/apps/web/features/runtimes/components/usage-section.tsx +++ b/apps/web/features/runtimes/components/usage-section.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { BarChart3 } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; import type { RuntimeUsage } from "@/shared/types"; import { api } from "@/shared/api"; import { formatTokens, estimateCost, aggregateByDate } from "../utils"; @@ -38,7 +39,22 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) { if (loading) { return ( -
Loading usage...
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ + +
+
); } diff --git a/apps/web/features/skills/components/skills-page.tsx b/apps/web/features/skills/components/skills-page.tsx index 89b3e8c9..645d7428 100644 --- a/apps/web/features/skills/components/skills-page.tsx +++ b/apps/web/features/skills/components/skills-page.tsx @@ -30,6 +30,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { toast } from "sonner"; +import { Skeleton } from "@/components/ui/skeleton"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; @@ -352,6 +354,7 @@ function SkillDetail({ ); const [selectedPath, setSelectedPath] = useState(SKILL_MD); const [saving, setSaving] = useState(false); + const [loadingFiles, setLoadingFiles] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [showAddFile, setShowAddFile] = useState(false); @@ -365,10 +368,13 @@ function SkillDetail({ // Fetch full skill (with files) on selection change useEffect(() => { setSelectedPath(SKILL_MD); + setLoadingFiles(true); api.getSkill(skill.id).then((full) => { useWorkspaceStore.getState().upsertSkill(full); setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content }))); - }); + }).catch((e) => { + toast.error(e instanceof Error ? e.message : "Failed to load skill files"); + }).finally(() => setLoadingFiles(false)); }, [skill.id]); // Build the virtual file map @@ -392,6 +398,8 @@ function SkillDetail({ content, files: files.filter((f) => f.path.trim()), }); + } catch { + // toast handled by parent } finally { setSaving(false); } @@ -514,22 +522,40 @@ function SkillDetail({
- + {loadingFiles ? ( +
+ + + +
+ ) : ( + + )}
{/* File viewer */}
+ {loadingFiles ? ( +
+ + + + + +
+ ) : ( + )}
@@ -604,34 +630,83 @@ export default function SkillsPage() { const skill = await api.createSkill(data); upsertSkill(skill); setSelectedId(skill.id); + toast.success("Skill created"); }; const handleImport = async (url: string) => { const skill = await api.importSkill({ url }); upsertSkill(skill); setSelectedId(skill.id); + toast.success("Skill imported"); }; const handleUpdate = async (id: string, data: UpdateSkillRequest) => { - const updated = await api.updateSkill(id, data); - upsertSkill(updated); + try { + const updated = await api.updateSkill(id, data); + upsertSkill(updated); + toast.success("Skill saved"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save skill"); + throw e; + } }; const handleDelete = async (id: string) => { - await api.deleteSkill(id); - if (selectedId === id) { - const remaining = skills.filter((s) => s.id !== id); - setSelectedId(remaining[0]?.id ?? ""); + try { + await api.deleteSkill(id); + if (selectedId === id) { + const remaining = skills.filter((s) => s.id !== id); + setSelectedId(remaining[0]?.id ?? ""); + } + removeSkill(id); + toast.success("Skill deleted"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to delete skill"); } - removeSkill(id); }; const selected = skills.find((s) => s.id === selectedId) ?? null; if (isLoading) { return ( -
- Loading... +
+ {/* List skeleton */} +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ {/* Detail skeleton */} +
+
+ + + +
+
+
+ + +
+
+ + + +
+
+
); } diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 75fe7fef..591cc7b9 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -5,6 +5,7 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types"; import { useIssueStore } from "@/features/issues"; import { useInboxStore } from "@/features/inbox"; import { useRuntimeStore } from "@/features/runtimes"; +import { toast } from "sonner"; import { api } from "@/shared/api"; import { createLogger } from "@/shared/logger"; @@ -76,11 +77,19 @@ export const useWorkspaceStore = create((set, get) => ({ logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id); const [nextMembers, nextAgents, nextSkills] = await Promise.all([ - api.listMembers(nextWorkspace.id), - api.listAgents({ workspace_id: nextWorkspace.id }), + api.listMembers(nextWorkspace.id).catch((e) => { + logger.error("failed to load members", e); + toast.error("Failed to load members"); + return [] as MemberWithUser[]; + }), + api.listAgents({ workspace_id: nextWorkspace.id }).catch((e) => { + logger.error("failed to load agents", e); + toast.error("Failed to load agents"); + return [] as Agent[]; + }), api.listSkills().catch(() => [] as Skill[]), - useIssueStore.getState().fetch(), - useInboxStore.getState().fetch(), + useIssueStore.getState().fetch().catch(() => {}), + useInboxStore.getState().fetch().catch(() => {}), ]); logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length); set({ members: nextMembers, agents: nextAgents, skills: nextSkills }); @@ -113,16 +122,27 @@ export const useWorkspaceStore = create((set, get) => ({ refreshWorkspaces: async () => { const { workspace, hydrateWorkspace } = get(); const storedWorkspaceId = localStorage.getItem("multica_workspace_id"); - const wsList = await api.listWorkspaces(); - await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId); - return wsList; + try { + const wsList = await api.listWorkspaces(); + await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId); + return wsList; + } catch (e) { + logger.error("failed to refresh workspaces", e); + toast.error("Failed to refresh workspaces"); + return get().workspaces; + } }, refreshMembers: async () => { const { workspace } = get(); if (!workspace) return; - const members = await api.listMembers(workspace.id); - set({ members }); + try { + const members = await api.listMembers(workspace.id); + set({ members }); + } catch (e) { + logger.error("failed to refresh members", e); + toast.error("Failed to refresh members"); + } }, updateAgent: (id, updates) => @@ -133,23 +153,33 @@ export const useWorkspaceStore = create((set, get) => ({ refreshAgents: async () => { const { workspace } = get(); if (!workspace) return; - const agents = await api.listAgents({ workspace_id: workspace.id }); - set({ agents }); + try { + const agents = await api.listAgents({ workspace_id: workspace.id }); + set({ agents }); + } catch (e) { + logger.error("failed to refresh agents", e); + toast.error("Failed to refresh agents"); + } }, refreshSkills: async () => { const { workspace, skills: existing } = get(); if (!workspace) return; - const fetched = await api.listSkills(); - // listSkills doesn't include files — preserve files from existing entries - const filesById = new Map( - existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]), - ); - const merged = fetched.map((s) => ({ - ...s, - files: s.files ?? filesById.get(s.id) ?? [], - })); - set({ skills: merged }); + try { + const fetched = await api.listSkills(); + // listSkills doesn't include files — preserve files from existing entries + const filesById = new Map( + existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]), + ); + const merged = fetched.map((s) => ({ + ...s, + files: s.files ?? filesById.get(s.id) ?? [], + })); + set({ skills: merged }); + } catch (e) { + logger.error("failed to refresh skills", e); + toast.error("Failed to refresh skills"); + } }, upsertSkill: (skill) => {