import { useEffect, useMemo, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Download, Search, TrendingUp, } from "lucide-react"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useToast } from "@/context/ToastContext"; import { skillRegistryApi } from "@/api/skillRegistry"; import { agentsApi } from "@/api/agents"; import { queryKeys } from "@/lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { SkillCard } from "@/components/SkillCard"; import { EmptyState } from "@/components/EmptyState"; import { FilterBar } from "@/components/FilterBar"; import type { FilterValue } from "@/components/FilterBar"; import { PageTabBar } from "@/components/PageTabBar"; import { PageSkeleton } from "@/components/PageSkeleton"; import { Identity } from "@/components/Identity"; import { cn } from "@/lib/utils"; type SortBy = "rating" | "name" | "recent"; export function SkillBrowser() { const { selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const { pushToast } = useToast(); // Tab state const [tab, setTab] = useState<"browse" | "installed" | "trending">("browse"); // Browse tab filter state const [search, setSearch] = useState(""); const [sourceFilter, setSourceFilter] = useState(null); const [categoryFilter, setCategoryFilter] = useState(null); const [sortBy, setSortBy] = useState("rating"); // Dialog state const [installDialog, setInstallDialog] = useState<{ skillId: string; isUpdate?: boolean } | null>(null); const [agentSkillsDir, setAgentSkillsDir] = useState(""); const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string } | null>(null); useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Workspace", href: "/dashboard" }, { label: "Skills" }, ]); }, [selectedCompany?.name, setBreadcrumbs]); // Data fetching const { data: skills = [], isLoading, isError } = useQuery({ queryKey: queryKeys.skillRegistry.list, queryFn: () => skillRegistryApi.list(), }); const { data: agents = [] } = useQuery({ queryKey: queryKeys.agents.list(selectedCompany?.id ?? ""), queryFn: () => agentsApi.list(selectedCompany?.id ?? ""), enabled: !!selectedCompany?.id, }); // Mutations const fetchMutation = useMutation({ mutationFn: () => skillRegistryApi.fetch(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); pushToast({ title: "Registry refreshed", tone: "success" }); }, onError: (err: Error) => { pushToast({ title: "Registry refresh failed", body: err.message, tone: "error" }); }, }); const installMutation = useMutation({ mutationFn: (params: { skillId: string; agentSkillsDir: string }) => skillRegistryApi.install(params.skillId, params.agentSkillsDir), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); pushToast({ title: "Skill installed", tone: "success" }); }, onError: (err: Error) => { pushToast({ title: "Install failed", body: err.message, tone: "error" }); }, }); const updateMutation = useMutation({ mutationFn: (params: { skillId: string; agentSkillsDir: string }) => skillRegistryApi.install(params.skillId, params.agentSkillsDir), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); pushToast({ title: "Skill updated", tone: "success" }); }, onError: (err: Error) => { pushToast({ title: "Update failed", body: err.message, tone: "error" }); }, }); const rollbackMutation = useMutation({ mutationFn: (params: { skillId: string; versionId: string; agentSkillsDir: string }) => skillRegistryApi.rollback(params.skillId, params.versionId, params.agentSkillsDir), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); pushToast({ title: "Rolled back to previous version", tone: "success" }); }, onError: (err: Error) => { pushToast({ title: "Rollback failed", body: err.message, tone: "error" }); }, }); const removeMutation = useMutation({ mutationFn: (skillId: string) => skillRegistryApi.remove(skillId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); pushToast({ title: "Skill uninstalled", tone: "success" }); }, }); // Derived data for filters const sources = useMemo( () => [...new Set(skills.map((s) => s.sourceId))].sort(), [skills], ); const categories = useMemo( () => [...new Set(skills.map((s) => s.category).filter(Boolean) as string[])].sort(), [skills], ); // Browse tab filtering const filteredSkills = useMemo(() => { let result = skills.filter((s) => !s.removedAt); if (search) { result = result.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.description?.toLowerCase().includes(search.toLowerCase()), ); } if (sourceFilter) result = result.filter((s) => s.sourceId === sourceFilter); if (categoryFilter) result = result.filter((s) => s.category === categoryFilter); result = [...result]; if (sortBy === "rating") result.sort((a, b) => (b.averageRating ?? 0) - (a.averageRating ?? 0)); else if (sortBy === "name") result.sort((a, b) => a.name.localeCompare(b.name)); return result; }, [skills, search, sourceFilter, categoryFilter, sortBy]); // Active filter chips const activeFilters = useMemo(() => { const filters: FilterValue[] = []; if (sourceFilter) filters.push({ key: "source", label: "Source", value: sourceFilter }); if (categoryFilter) filters.push({ key: "category", label: "Category", value: categoryFilter }); return filters; }, [sourceFilter, categoryFilter]); const handleClearFilters = () => { setSourceFilter(null); setCategoryFilter(null); }; const handleRemoveFilter = (key: string) => { if (key === "source") setSourceFilter(null); if (key === "category") setCategoryFilter(null); }; // Installed tab grouping const installedGroups = useMemo(() => { const installed = skills.filter((s) => s.activeVersionId && !s.removedAt); if (installed.length === 0) return []; return [{ agentId: "all", agentName: "All Agents", skills: installed }]; }, [skills]); // Trending tab sections const activeSkills = useMemo(() => skills.filter((s) => !s.removedAt), [skills]); const gainingTraction = useMemo( () => [...activeSkills].sort((a, b) => (b.ratingCount ?? 0) - (a.ratingCount ?? 0)).slice(0, 6), [activeSkills], ); const recentlyUpdated = useMemo( () => [...activeSkills].sort((a, b) => b.id.localeCompare(a.id)).slice(0, 6), [activeSkills], ); const youMightLike = useMemo(() => { const installedCategories = new Set( activeSkills .filter((s) => s.activeVersionId) .map((s) => s.category) .filter(Boolean), ); if (installedCategories.size === 0) return activeSkills.filter((s) => !s.activeVersionId).slice(0, 6); return activeSkills .filter((s) => !s.activeVersionId && s.category && installedCategories.has(s.category)) .slice(0, 6); }, [activeSkills]); const handleRollback = (skillId: string) => { // Rollback requires a versionId — without version selection UI, use a no-op for now. // Full rollback flow is in Plan 03 (SkillDetail page). pushToast({ title: "Select a version from the skill detail page to roll back.", tone: "info" }); void skillId; }; const handleInstallForAgent = (agentId: string) => { if (!installDialog) return; const dir = agentSkillsDir.trim() || `/agents/${agentId}/.claude/skills`; if (installDialog.isUpdate) { updateMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir }); } else { installMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir }); } setInstallDialog(null); setAgentSkillsDir(""); }; const tabItems = [ { value: "browse", label: "Browse" }, { value: "installed", label: "Installed" }, { value: "trending", label: "Trending" }, ]; return (
{/* Header */}

Skills

{/* Error state */} {isError && (

Failed to load skills. Check that the skill registry backend is running and try again.

)} {/* Loading state */} {isLoading && } {/* Tabs */} {!isLoading && ( setTab(v as typeof tab)}> setTab(v as typeof tab)} align="start" /> {/* Browse tab */} {/* Toolbar */}
setSearch(e.target.value)} />
{/* Active filter chips */} {/* Skill grid */} {filteredSkills.length > 0 && (
{filteredSkills.map((skill) => ( setInstallDialog({ skillId: skill.id })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onRollback={() => handleRollback(skill.id)} onUninstall={() => setUninstallDialog({ skillId: skill.id })} /> ))}
)} {/* Browse empty state */} {filteredSkills.length === 0 && ( fetchMutation.mutate()} /> )}
{/* Installed tab */} {installedGroups.length === 0 && ( setTab("browse")} /> )} {installedGroups.map((group) => (
{group.skills.length}
{group.skills.map((skill) => ( handleRollback(skill.id)} onUninstall={() => setUninstallDialog({ skillId: skill.id })} /> ))}
))}
{/* Trending tab */} {skills.length === 0 && ( )} {skills.length > 0 && ( <>

Gaining Traction

{gainingTraction.map((skill) => ( setInstallDialog({ skillId: skill.id })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onRollback={() => handleRollback(skill.id)} onUninstall={() => setUninstallDialog({ skillId: skill.id })} /> ))}

Recently Updated

{recentlyUpdated.map((skill) => ( setInstallDialog({ skillId: skill.id })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onRollback={() => handleRollback(skill.id)} onUninstall={() => setUninstallDialog({ skillId: skill.id })} /> ))}

You Might Like

{youMightLike.map((skill) => ( setInstallDialog({ skillId: skill.id })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onRollback={() => handleRollback(skill.id)} onUninstall={() => setUninstallDialog({ skillId: skill.id })} /> ))}
)}
)} {/* Agent selector dialog (install / update) */} { if (!open) { setInstallDialog(null); setAgentSkillsDir(""); } }} > Select agent Choose which agent should receive this skill.
setAgentSkillsDir(e.target.value)} />
{agents.length === 0 && (

No agents found in this workspace.

)} {agents.map((agent) => ( ))}
{/* Uninstall confirmation dialog */} !open && setUninstallDialog(null)} > Uninstall skill? This will remove the skill files from the agent's directory. You can reinstall it later.
); }