diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6afb8d24..0c15d5fa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -654,6 +654,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ diff:
+ specifier: ^8.0.4
+ version: 8.0.4
lexical:
specifier: 0.35.0
version: 0.35.0
@@ -688,6 +691,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.0.7
version: 4.1.18(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
+ '@types/diff':
+ specifier: ^8.0.0
+ version: 8.0.0
'@types/node':
specifier: ^25.2.3
version: 25.2.3
@@ -3453,6 +3459,10 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/diff@8.0.0':
+ resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==}
+ deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed.
+
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -4192,6 +4202,10 @@ packages:
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
engines: {node: '>=0.3.1'}
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
+ engines: {node: '>=0.3.1'}
+
dompurify@3.3.2:
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
engines: {node: '>=20'}
@@ -9408,6 +9422,10 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/diff@8.0.0':
+ dependencies:
+ diff: 8.0.4
+
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -10143,6 +10161,8 @@ snapshots:
diff@5.2.2: {}
+ diff@8.0.4: {}
+
dompurify@3.3.2:
optionalDependencies:
'@types/trusted-types': 2.0.7
diff --git a/ui/package.json b/ui/package.json
index c63a5a29..264eb367 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -30,7 +30,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lexical/link": "0.35.0",
- "lexical": "0.35.0",
"@mdxeditor/editor": "^3.52.4",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
@@ -48,6 +47,8 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "diff": "^8.0.4",
+ "lexical": "0.35.0",
"lucide-react": "^0.574.0",
"mermaid": "^11.12.0",
"radix-ui": "^1.4.3",
@@ -60,6 +61,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
+ "@types/diff": "^8.0.0",
"@types/node": "^25.2.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
diff --git a/ui/src/pages/SkillDetail.test.tsx b/ui/src/pages/SkillDetail.test.tsx
new file mode 100644
index 00000000..ca717f92
--- /dev/null
+++ b/ui/src/pages/SkillDetail.test.tsx
@@ -0,0 +1,81 @@
+// @vitest-environment node
+
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+vi.mock("react-router-dom", () => ({
+ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode; [k: string]: unknown }) => (
+ {children}
+ ),
+ useParams: () => ({ skillId: "test-source%2Ftest-skill" }),
+ useNavigate: () => () => {},
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
+ if (
+ Array.isArray(queryKey) &&
+ (queryKey[3] === "versions" || String(queryKey[queryKey.length - 1]) === "versions")
+ ) {
+ return { data: [], isLoading: false, isError: false };
+ }
+ return {
+ data: {
+ id: "test-source/test-skill",
+ name: "Test Skill",
+ description: "A test description",
+ sourceId: "test-source",
+ category: "testing",
+ activeVersionId: null,
+ removedAt: null,
+ averageRating: 4.2,
+ ratingCount: 10,
+ },
+ isLoading: false,
+ isError: false,
+ };
+ },
+ useQueryClient: () => ({ invalidateQueries: () => {} }),
+ useMutation: () => ({ mutate: () => {}, isPending: false }),
+}));
+
+vi.mock("../context/BreadcrumbContext", () => ({
+ useBreadcrumbs: () => ({ setBreadcrumbs: () => {} }),
+}));
+
+vi.mock("../context/CompanyContext", () => ({
+ useCompany: () => ({ selectedCompany: { id: "test", name: "Test Workspace" } }),
+}));
+
+vi.mock("../context/ToastContext", () => ({
+ useToast: () => ({ pushToast: () => {} }),
+}));
+
+vi.mock("../context/SidebarContext", () => ({
+ useSidebar: () => ({ isMobile: false }),
+}));
+
+import { SkillDetail } from "./SkillDetail";
+
+describe("SkillDetail", () => {
+ it("renders skill name heading", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("Test Skill");
+ });
+
+ it("renders source badge", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("test-source");
+ });
+
+ it("renders back link to Skills", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("Skills");
+ });
+
+ it("renders overview tab with rating", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("4.2");
+ expect(html).toContain("Average rating");
+ });
+});
diff --git a/ui/src/pages/SkillDetail.tsx b/ui/src/pages/SkillDetail.tsx
index 69f7f518..0af0f018 100644
--- a/ui/src/pages/SkillDetail.tsx
+++ b/ui/src/pages/SkillDetail.tsx
@@ -1,3 +1,484 @@
-export function SkillDetail() {
- return
SkillDetail placeholder
;
+import { useEffect, useState } from "react";
+import { useParams, Link } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { diffLines } from "diff";
+import { ChevronLeft, Star, Download, RotateCcw, Trash2 } from "lucide-react";
+import { useCompany } from "@/context/CompanyContext";
+import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useToast } from "@/context/ToastContext";
+import { skillRegistryApi } from "@/api/skillRegistry";
+import { queryKeys } from "@/lib/queryKeys";
+import { cn, relativeTime } from "@/lib/utils";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Tabs, TabsContent } from "@/components/ui/tabs";
+import { PageTabBar } from "@/components/PageTabBar";
+import { PageSkeleton } from "@/components/PageSkeleton";
+
+/* ------------------------------------------------------------------ */
+/* VersionDiff component */
+/* ------------------------------------------------------------------ */
+
+function VersionDiff({ oldContent, newContent }: { oldContent: string; newContent: string }) {
+ const parts = diffLines(oldContent, newContent);
+ return (
+
+ {parts.map((part, i) => (
+
+ {part.value}
+
+ ))}
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* SkillDetail page */
+/* ------------------------------------------------------------------ */
+
+export function SkillDetail() {
+ const { skillId: rawSkillId } = useParams<{ skillId: string }>();
+ const skillId = rawSkillId ? decodeURIComponent(rawSkillId) : "";
+
+ const { selectedCompany } = useCompany();
+ const { setBreadcrumbs } = useBreadcrumbs();
+ const { pushToast } = useToast();
+ const queryClient = useQueryClient();
+
+ const [detailTab, setDetailTab] = useState("overview");
+ const [versionA, setVersionA] = useState("");
+ const [versionB, setVersionB] = useState("");
+
+ // Install dialog state — used for both install and update flows
+ const [installDialog, setInstallDialog] = useState<{
+ skillId: string;
+ isUpdate: boolean;
+ agentSkillsDir?: string;
+ } | null>(null);
+
+ // Uninstall confirmation dialog state
+ const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string } | null>(null);
+
+ /* ---------------------------------------------------------------- */
+ /* Data queries */
+ /* ---------------------------------------------------------------- */
+
+ const { data: skill, isLoading, isError } = useQuery({
+ queryKey: queryKeys.skillRegistry.detail(skillId),
+ queryFn: () => skillRegistryApi.getById(skillId),
+ enabled: !!skillId,
+ });
+
+ const { data: versions = [] } = useQuery({
+ queryKey: queryKeys.skillRegistry.versions(skillId),
+ queryFn: () => skillRegistryApi.getVersions(skillId),
+ enabled: !!skillId,
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* Breadcrumbs */
+ /* ---------------------------------------------------------------- */
+
+ useEffect(() => {
+ setBreadcrumbs([
+ { label: selectedCompany?.name ?? "Workspace", href: "/dashboard" },
+ { label: "Skills", href: "../skills" },
+ { label: skill?.name ?? "Skill" },
+ ]);
+ }, [selectedCompany?.name, skill?.name, setBreadcrumbs]);
+
+ /* ---------------------------------------------------------------- */
+ /* Mutations */
+ /* ---------------------------------------------------------------- */
+
+ const invalidateSkill = () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.detail(skillId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
+ };
+
+ const installMutation = useMutation({
+ mutationFn: ({ agentSkillsDir }: { agentSkillsDir: string }) =>
+ skillRegistryApi.install(skillId, agentSkillsDir),
+ onSuccess: () => {
+ invalidateSkill();
+ setInstallDialog(null);
+ pushToast({ title: "Skill installed", tone: "success" });
+ },
+ onError: (err: Error) => {
+ pushToast({ title: "Install failed", body: err.message, tone: "error" });
+ },
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: ({ agentSkillsDir }: { agentSkillsDir: string }) =>
+ skillRegistryApi.install(skillId, agentSkillsDir),
+ onSuccess: () => {
+ invalidateSkill();
+ setInstallDialog(null);
+ pushToast({ title: "Skill updated", tone: "success" });
+ },
+ onError: (err: Error) => {
+ pushToast({ title: "Install failed", body: err.message, tone: "error" });
+ },
+ });
+
+ const rollbackMutation = useMutation({
+ mutationFn: ({ versionId, agentSkillsDir }: { versionId: string; agentSkillsDir: string }) =>
+ skillRegistryApi.rollback(skillId, versionId, agentSkillsDir),
+ onSuccess: () => {
+ invalidateSkill();
+ 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: () => skillRegistryApi.remove(skillId),
+ onSuccess: () => {
+ invalidateSkill();
+ setUninstallDialog(null);
+ pushToast({ title: "Skill uninstalled", tone: "success" });
+ },
+ onError: (err: Error) => {
+ pushToast({ title: "Rollback failed", body: err.message, tone: "error" });
+ },
+ });
+
+ /* ---------------------------------------------------------------- */
+ /* Loading / error states */
+ /* ---------------------------------------------------------------- */
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+ Failed to load skills. Check that the skill registry backend is running and try again.
+
+
+ );
+ }
+
+ if (!skill) {
+ return (
+
+ Skill not found.
+
+ );
+ }
+
+ const isInstalled = !!skill.activeVersionId;
+ const isMutating =
+ installMutation.isPending ||
+ updateMutation.isPending ||
+ rollbackMutation.isPending ||
+ removeMutation.isPending;
+
+ /* ---------------------------------------------------------------- */
+ /* Render */
+ /* ---------------------------------------------------------------- */
+
+ return (
+
+ {/* Back link */}
+
+ Skills
+
+
+ {/* Header */}
+
+
+
{skill.name}
+
+ {skill.sourceId}
+
+
+
+ {!isInstalled && (
+
+ )}
+ {isInstalled && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {/* Description */}
+ {skill.description && (
+ {skill.description}
+ )}
+
+ {/* Detail tabs */}
+
+
+
+ {/* Overview tab */}
+
+
+
+ Installs
+ {skill.ratingCount ?? "\u2014"}
+
+
+ Average rating
+
+ {skill.averageRating != null ? (
+ <>
+
+ {skill.averageRating.toFixed(1)}
+ >
+ ) : (
+ "No ratings yet"
+ )}
+
+
+ {skill.averageRating != null && skill.ratingCount != null && (
+
+ Community
+ ({skill.ratingCount} ratings)
+
+ )}
+
+ Source
+ {skill.sourceId}
+
+
+ Active version
+
+ {skill.activeVersionId
+ ? `v${skill.activeVersionId.split("@").pop() ?? skill.activeVersionId}`
+ : "\u2014"}
+
+
+
+
+
+ {/* Versions tab */}
+
+ {versions.length === 0 ? (
+ No versions fetched yet.
+ ) : (
+
+
+ {versions.map((v) => (
+
+
+ {v.version}
+
+ {relativeTime(new Date(v.fetchedAt))}
+
+
+ {v.id === skill.activeVersionId && (
+
+ Active
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ {/* Diff tab */}
+
+
+
+
+
+ {versionA && versionB ? (
+
+
+ Version diff requires file content endpoint (coming in a future update).
+
+
+
+ ) : (
+
+ Select two versions above to compare them.
+
+ )}
+
+
+
+ {/* Install / Update dialog */}
+
+
+ {/* Uninstall confirmation dialog */}
+
+
+ );
}