nexus/ui/src/pages/SkillDetail.tsx
Mikkel Georgsen bbcbc86035 feat(12-02): SkillDetail Ratings tab and Overview usage stats
- Add Ratings tab (4th tab) with rate form, personal history, community section
- Overview tab: conditionally render taskCount, avgCostUsd, lastUsedAt rows
- Import StarRating, Separator, Skeleton, Textarea, EmptyState, TooltipProvider
- saveRatingMutation calls addRating, invalidates ratings query on success
- ratingsQuery loads personal history with loading/empty/error states
2026-04-02 15:08:50 +00:00

665 lines
25 KiB
TypeScript

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 { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PageTabBar } from "@/components/PageTabBar";
import { PageSkeleton } from "@/components/PageSkeleton";
import { EmptyState } from "@/components/EmptyState";
import { StarRating } from "@/components/StarRating";
/* ------------------------------------------------------------------ */
/* 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>("");
// Rating form state
const [pendingStars, setPendingStars] = useState(0);
const [pendingNote, setPendingNote] = 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,
});
const {
data: ratings = [],
isLoading: ratingsLoading,
isError: ratingsError,
} = useQuery({
queryKey: ["skill-ratings", skillId],
queryFn: () => skillRegistryApi.getRatings(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: "Update 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: "Uninstall failed", body: err.message, tone: "error" });
},
});
const saveRatingMutation = useMutation({
mutationFn: ({ stars, note }: { stars: number; note: string }) =>
skillRegistryApi.addRating(skillId, {
stars,
versionId: skill?.activeVersionId ?? undefined,
note: note.trim() || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skill-ratings", skillId] });
setPendingStars(0);
setPendingNote("");
pushToast({ title: "Rating saved", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Failed to save rating", 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 */}
<TooltipProvider>
<Tabs value={detailTab} onValueChange={setDetailTab}>
<PageTabBar
items={[
{ value: "overview", label: "Overview" },
{ value: "versions", label: "Versions" },
{ value: "diff", label: "Diff" },
{ value: "ratings", label: "Ratings" },
]}
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-semibold">{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-semibold">
{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-semibold">({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>
{/* Usage stats — Phase 12 */}
{skill.taskCount != null && skill.taskCount > 0 && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Tasks completed</span>
<span className="text-sm font-semibold">{skill.taskCount}</span>
</div>
)}
{skill.avgCostUsd != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Avg task cost</span>
<span className="text-sm font-semibold">${skill.avgCostUsd.toFixed(4)}</span>
</div>
)}
{skill.lastUsedAt != null && (
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Last used</span>
<span className="text-sm font-semibold">{relativeTime(new Date(skill.lastUsedAt))}</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>
{/* Ratings tab */}
<TabsContent value="ratings" className="pt-4 space-y-6">
{/* Section 1: Rate this skill */}
<div className="space-y-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Rate this skill
</h2>
<div className="space-y-2">
<StarRating value={pendingStars} onChange={setPendingStars} size="md" />
<Textarea
placeholder="Add a note about this version\u2026"
value={pendingNote}
onChange={(e) => setPendingNote(e.target.value)}
rows={3}
className="resize-none"
/>
</div>
<Button
size="sm"
variant="default"
disabled={pendingStars === 0 || saveRatingMutation.isPending}
onClick={() => {
if (pendingStars === 0) return;
saveRatingMutation.mutate({ stars: pendingStars, note: pendingNote });
}}
>
{saveRatingMutation.isPending ? "Saving\u2026" : "Save rating"}
</Button>
{saveRatingMutation.isError && (
<p className="text-sm text-destructive">
{(saveRatingMutation.error as Error).message}
</p>
)}
</div>
<Separator />
{/* Section 2: Your Rating History */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Your Rating History
</h2>
{ratingsError && (
<p className="text-sm text-destructive">Failed to load ratings.</p>
)}
{ratingsLoading && (
<div aria-hidden="true" className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
)}
{!ratingsLoading && !ratingsError && ratings.length === 0 && (
<EmptyState
icon={Star}
message="No ratings yet"
/>
)}
{!ratingsLoading && !ratingsError && ratings.length > 0 && (
<ScrollArea className="max-h-80">
<div className="space-y-1">
{ratings.map((r) => (
<div
key={r.id}
className="flex items-start justify-between py-2 px-2 rounded hover:bg-accent/50"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<StarRating value={r.stars} readonly size="sm" />
{r.versionId && (
<Badge variant="outline" className="text-xs font-mono">
{r.versionId.slice(-7)}
</Badge>
)}
</div>
{r.note && (
<p className="text-sm text-muted-foreground">{r.note}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 ml-4">
{relativeTime(new Date(r.createdAt))}
</span>
</div>
))}
</div>
</ScrollArea>
)}
</div>
<Separator />
{/* Section 3: Community Ratings */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Community Ratings
</h2>
{skill.averageRating != null ? (
<div className="space-y-1">
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Average</span>
<span className="flex items-center gap-2">
<StarRating value={Math.round(skill.averageRating)} readonly size="sm" />
<span className="text-sm font-semibold">{skill.averageRating.toFixed(1)}</span>
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Ratings</span>
<span className="text-sm font-semibold">{skill.ratingCount}</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No community ratings available.</p>
)}
</div>
</TabsContent>
</Tabs>
</TooltipProvider>
{/* 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&apos;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&apos;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>
);
}