diff --git a/ui/src/pages/SkillBrowser.tsx b/ui/src/pages/SkillBrowser.tsx index d04e32eb..6819836f 100644 --- a/ui/src/pages/SkillBrowser.tsx +++ b/ui/src/pages/SkillBrowser.tsx @@ -1,3 +1,563 @@ +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() { - return
SkillBrowser placeholder
; + 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. + + + + + + + + +
+ ); }