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 { skillGroupsApi } from "@/api/skillGroups"; 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"; import { getUIAdapter } from "@/adapters"; import { resolveAdapterSkillConfig, listAdapterSkillConfigs } from "@paperclipai/adapter-utils"; // Compute compatible adapter labels once at module level (used by Browse/Trending SkillCards) const COMPATIBLE_ADAPTER_LABELS = listAdapterSkillConfigs() .filter((c) => c.supportsInstall) .map((c) => getUIAdapter(c.adapterType).label); 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"); // Installed tab: selected agent for per-agent skill view const [selectedAgentId, setSelectedAgentId] = useState(null); // Dialog state const [installDialog, setInstallDialog] = useState<{ skillId: string; isUpdate?: boolean } | null>(null); const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string; agentId: string } | null>(null); const [unsupportedMessage, setUnsupportedMessage] = useState(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, }); // Per-agent installed skills (for Installed tab) const { data: agentInstalledSkills = [] } = useQuery({ queryKey: ["agentInstalledSkills", selectedAgentId], queryFn: () => skillGroupsApi.listAgentSkills(selectedAgentId!), enabled: tab === "installed" && !!selectedAgentId, }); // 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; agentId: string }) => skillRegistryApi.install(params.skillId, params.agentId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); if (selectedAgentId) { queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] }); } 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; agentId: string }) => skillRegistryApi.install(params.skillId, params.agentId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); if (selectedAgentId) { queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] }); } 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; agentId: string }) => skillRegistryApi.rollback(params.skillId, params.versionId, params.agentId), 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: (params: { skillId: string; agentId: string }) => skillRegistryApi.uninstall(params.skillId, params.agentId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); if (selectedAgentId) { queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] }); } 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: split into managed/native const managedSkills = useMemo( () => agentInstalledSkills.filter((s) => s.source === "managed"), [agentInstalledSkills], ); const nativeSkills = useMemo( () => agentInstalledSkills.filter((s) => s.source === "native"), [agentInstalledSkills], ); // Helper: get SkillListItem by skillId (for rendering in Installed tab) const skillById = useMemo(() => { const map = new Map(skills.map((s) => [s.id, s])); return map; }, [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 agent = agents.find((a) => a.id === agentId); if (agent) { const cfg = resolveAdapterSkillConfig(agent.adapterType ?? "process"); if (!cfg.supportsInstall) { setUnsupportedMessage( cfg.unsupportedReason ?? "This adapter does not support skill installation." ); return; } } if (installDialog.isUpdate) { updateMutation.mutate({ skillId: installDialog.skillId, agentId }); } else { installMutation.mutate({ skillId: installDialog.skillId, agentId }); } setInstallDialog(null); }; 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={() => { // Browse tab uninstall: no specific agentId context — open install dialog to select agent first void skill.id; }} /> ))}
)} {/* Browse empty state */} {filteredSkills.length === 0 && ( fetchMutation.mutate()} /> )}
{/* Installed tab */} {/* Agent selector for installed tab */} {agents.length === 0 && ( )} {agents.length > 0 && !selectedAgentId && (

Select an agent to view installed skills:

{agents.map((agent) => ( ))}
)} {selectedAgentId && (
{/* Back to agent selector */}
{agents.find((a) => a.id === selectedAgentId)?.name ?? selectedAgentId} {(() => { const a = agents.find((ag) => ag.id === selectedAgentId); return a ? ( ({getUIAdapter(a.adapterType ?? "process").label}) ) : null; })()}
{agentInstalledSkills.length === 0 && ( setTab("browse")} /> )} {/* Managed skills section */} {managedSkills.length > 0 && ( <> {nativeSkills.length > 0 && (

Managed

)}
{managedSkills.map((entry) => { const skill = skillById.get(entry.skillId); if (!skill) return null; return ( handleRollback(entry.skillId)} onUninstall={() => setUninstallDialog({ skillId: entry.skillId, agentId: selectedAgentId })} /> ); })}
)} {/* Native skills section */} {nativeSkills.length > 0 && ( <>

Native

{nativeSkills.map((entry) => { const skill = skillById.get(entry.skillId); if (!skill) return null; return ( ); })}
)}
)}
{/* 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={() => void skill.id} /> ))}

Recently Updated

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

You Might Like

{youMightLike.map((skill) => ( setInstallDialog({ skillId: skill.id })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onRollback={() => handleRollback(skill.id)} onUninstall={() => void skill.id} /> ))}
)}
)} {/* Agent selector dialog (install / update) */} { if (!open) { setInstallDialog(null); setUnsupportedMessage(null); } }} > Select agent Choose which agent should receive this skill.
{unsupportedMessage && (

Cannot install on this agent

{unsupportedMessage}

)}
{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.
); }