diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 486eac08..0c15d5fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,8 +516,8 @@ importers: specifier: ^5.1.0 version: 5.2.1 hermes-paperclip-adapter: - specifier: ^0.2.0 - version: 0.2.0 + specifier: 0.1.1 + version: 0.1.1 jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@2.0.1) @@ -654,9 +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) - hermes-paperclip-adapter: - specifier: ^0.2.0 - version: 0.2.0 + diff: + specifier: ^8.0.4 + version: 8.0.4 lexical: specifier: 0.35.0 version: 0.35.0 @@ -691,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 @@ -2118,8 +2121,8 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - '@paperclipai/adapter-utils@2026.325.0': - resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==} + '@paperclipai/adapter-utils@0.3.1': + resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -3456,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==} @@ -4195,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'} @@ -4565,8 +4576,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hermes-paperclip-adapter@0.2.0: - resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==} + hermes-paperclip-adapter@0.1.1: + resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==} engines: {node: '>=20.0.0'} html-encoding-sniffer@6.0.0: @@ -7943,7 +7954,7 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} - '@paperclipai/adapter-utils@2026.325.0': {} + '@paperclipai/adapter-utils@0.3.1': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -9411,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 @@ -10146,6 +10161,8 @@ snapshots: diff@5.2.2: {} + diff@8.0.4: {} + dompurify@3.3.2: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -10560,9 +10577,9 @@ snapshots: help-me@5.0.0: {} - hermes-paperclip-adapter@0.2.0: + hermes-paperclip-adapter@0.1.1: dependencies: - '@paperclipai/adapter-utils': 2026.325.0 + '@paperclipai/adapter-utils': 0.3.1 picocolors: 1.1.1 html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): diff --git a/ui/package.json b/ui/package.json index fd85ab89..4e0041c2 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:*", @@ -49,6 +48,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", @@ -61,6 +62,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 */} + !open && setInstallDialog(null)}> + + + + {installDialog?.isUpdate ? "Update skill" : "Install skill"} + + + {installDialog?.isUpdate + ? "Update this skill to the latest version for your agents." + : "Install this skill to add new capabilities to your agents."} + + +
+

+ Agent skills directory is required to install. Enter the path to the agent's + skills directory. +

+ + setInstallDialog((d) => d ? { ...d, agentSkillsDir: e.target.value } : null) + } + /> +
+ + + + +
+
+ + {/* Uninstall confirmation dialog */} + !open && setUninstallDialog(null)}> + + + Uninstall skill? + + This will remove the skill files from the agent's directory. You can reinstall + it later. + + + + + + + + +
+ ); }