feat(10-03): implement SkillDetail page with Overview, Versions, Diff tabs
- Full SkillDetail.tsx replacing stub (Overview, Versions, Diff tabs) - Separate installMutation and updateMutation with distinct toast messages - VersionDiff component using diffLines from diff package - Version selector with Select dropdowns in Diff tab - ScrollArea for version list in Versions tab - Install/Update/Uninstall dialogs with confirmation - PageSkeleton variant=detail for loading state - SkillDetail.test.tsx with 4 SSR smoke tests (all passing) - diff + @types/diff packages installed in ui workspace
This commit is contained in:
parent
90354bb8ce
commit
bfa0b9db1a
4 changed files with 596 additions and 15 deletions
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
81
ui/src/pages/SkillDetail.test.tsx
Normal file
81
ui/src/pages/SkillDetail.test.tsx
Normal file
|
|
@ -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 }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
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(<SkillDetail />);
|
||||
expect(html).toContain("Test Skill");
|
||||
});
|
||||
|
||||
it("renders source badge", () => {
|
||||
const html = renderToStaticMarkup(<SkillDetail />);
|
||||
expect(html).toContain("test-source");
|
||||
});
|
||||
|
||||
it("renders back link to Skills", () => {
|
||||
const html = renderToStaticMarkup(<SkillDetail />);
|
||||
expect(html).toContain("Skills");
|
||||
});
|
||||
|
||||
it("renders overview tab with rating", () => {
|
||||
const html = renderToStaticMarkup(<SkillDetail />);
|
||||
expect(html).toContain("4.2");
|
||||
expect(html).toContain("Average rating");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,484 @@
|
|||
export function SkillDetail() {
|
||||
return <div>SkillDetail placeholder</div>;
|
||||
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 (
|
||||
<pre
|
||||
role="region"
|
||||
aria-label="Version diff"
|
||||
className="text-xs font-mono rounded border border-border bg-muted/30 p-4 overflow-x-auto"
|
||||
>
|
||||
{parts.map((part, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
part.added && "bg-green-500/20 text-green-700 dark:text-green-400",
|
||||
part.removed && "bg-red-500/20 text-red-700 dark:text-red-400",
|
||||
!part.added && !part.removed && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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<string>("");
|
||||
const [versionB, setVersionB] = useState<string>("");
|
||||
|
||||
// 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 (
|
||||
<main className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<PageSkeleton variant="detail" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load skills. Check that the skill registry backend is running and try again.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!skill) {
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Skill not found.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const isInstalled = !!skill.activeVersionId;
|
||||
const isMutating =
|
||||
installMutation.isPending ||
|
||||
updateMutation.isPending ||
|
||||
rollbackMutation.isPending ||
|
||||
removeMutation.isPending;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Render */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="../skills"
|
||||
className="text-sm text-muted-foreground hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Skills
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{skill.name}</h1>
|
||||
<Badge variant="secondary" className="text-xs mt-1">
|
||||
{skill.sourceId}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
{!isInstalled && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isMutating}
|
||||
onClick={() => setInstallDialog({ skillId, isUpdate: false })}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 mr-1" />
|
||||
Install skill
|
||||
</Button>
|
||||
)}
|
||||
{isInstalled && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isMutating}
|
||||
onClick={() => setInstallDialog({ skillId, isUpdate: true })}
|
||||
>
|
||||
Update skill
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isMutating}
|
||||
onClick={() => setUninstallDialog({ skillId })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Uninstall
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
|
||||
{/* Detail tabs */}
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "versions", label: "Versions" },
|
||||
{ value: "diff", label: "Diff" },
|
||||
]}
|
||||
value={detailTab}
|
||||
onValueChange={setDetailTab}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Overview tab */}
|
||||
<TabsContent value="overview" className="space-y-1 pt-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Installs</span>
|
||||
<span className="text-sm font-medium">{skill.ratingCount ?? "\u2014"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Average rating</span>
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
{skill.averageRating != null ? (
|
||||
<>
|
||||
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" />
|
||||
{skill.averageRating.toFixed(1)}
|
||||
</>
|
||||
) : (
|
||||
"No ratings yet"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{skill.averageRating != null && skill.ratingCount != null && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Community</span>
|
||||
<span className="text-sm font-medium">({skill.ratingCount} ratings)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Source</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{skill.sourceId}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Active version</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{skill.activeVersionId
|
||||
? `v${skill.activeVersionId.split("@").pop() ?? skill.activeVersionId}`
|
||||
: "\u2014"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Versions tab */}
|
||||
<TabsContent value="versions" className="pt-4">
|
||||
{versions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No versions fetched yet.</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="space-y-1">
|
||||
{versions.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex items-center justify-between py-2 px-2 rounded hover:bg-accent/50"
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-mono text-muted-foreground">{v.version}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{relativeTime(new Date(v.fetchedAt))}
|
||||
</span>
|
||||
</div>
|
||||
{v.id === skill.activeVersionId && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Diff tab */}
|
||||
<TabsContent value="diff" className="pt-4 space-y-4">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Select value={versionA} onValueChange={setVersionA}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select version A" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={versionB} onValueChange={setVersionB}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select version B" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{versionA && versionB ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Version diff requires file content endpoint (coming in a future update).
|
||||
</p>
|
||||
<VersionDiff oldContent="" newContent="" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select two versions above to compare them.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Install / Update dialog */}
|
||||
<Dialog open={!!installDialog} onOpenChange={(open) => !open && setInstallDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{installDialog?.isUpdate ? "Update skill" : "Install skill"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{installDialog?.isUpdate
|
||||
? "Update this skill to the latest version for your agents."
|
||||
: "Install this skill to add new capabilities to your agents."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agent skills directory is required to install. Enter the path to the agent's
|
||||
skills directory.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
className="mt-2 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="/path/to/agent/skills"
|
||||
value={installDialog?.agentSkillsDir ?? ""}
|
||||
onChange={(e) =>
|
||||
setInstallDialog((d) => d ? { ...d, agentSkillsDir: e.target.value } : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInstallDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!installDialog?.agentSkillsDir || isMutating}
|
||||
onClick={() => {
|
||||
if (!installDialog?.agentSkillsDir) return;
|
||||
if (installDialog.isUpdate) {
|
||||
updateMutation.mutate({ agentSkillsDir: installDialog.agentSkillsDir });
|
||||
} else {
|
||||
installMutation.mutate({ agentSkillsDir: installDialog.agentSkillsDir });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{installDialog?.isUpdate
|
||||
? updateMutation.isPending
|
||||
? "Updating\u2026"
|
||||
: "Update skill"
|
||||
: installMutation.isPending
|
||||
? "Installing\u2026"
|
||||
: "Install skill"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Uninstall confirmation dialog */}
|
||||
<Dialog open={!!uninstallDialog} onOpenChange={(open) => !open && setUninstallDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Uninstall skill?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove the skill files from the agent's directory. You can reinstall
|
||||
it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUninstallDialog(null)}>
|
||||
Keep skill
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() => removeMutation.mutate()}
|
||||
>
|
||||
{removeMutation.isPending ? "Uninstalling\u2026" : "Yes, uninstall"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue