feat(10-02): build SkillBrowser page with Browse tab, dialogs, and 5 mutations

- Replace stub with full three-tab page (Browse, Installed, Trending placeholders)
- Five separate mutations: fetch, install, update, rollback, remove with distinct toasts
- Agent selector dialog with skills directory input for install/update flow
- Uninstall confirmation dialog with destructive button
- Browse tab: search, source filter, category filter, sort, FilterBar, card grid, EmptyState
- Breadcrumbs wired via useBreadcrumbs, data via TanStack Query
This commit is contained in:
Mikkel Georgsen 2026-04-01 02:38:24 +02:00
parent e3e4450113
commit f492ec49f0

View file

@ -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 <div>SkillBrowser placeholder</div>;
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<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortBy>("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 (
<main className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Skills</h1>
<Button
variant="outline"
size="sm"
onClick={() => fetchMutation.mutate()}
disabled={fetchMutation.isPending}
>
{fetchMutation.isPending ? "Refreshing..." : "Refresh registry"}
</Button>
</div>
{/* Error state */}
{isError && (
<p className="text-sm text-destructive">
Failed to load skills. Check that the skill registry backend is running and try again.
</p>
)}
{/* Loading state */}
{isLoading && <PageSkeleton variant="list" />}
{/* Tabs */}
{!isLoading && (
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<PageTabBar items={tabItems} value={tab} onValueChange={(v) => setTab(v as typeof tab)} align="start" />
{/* Browse tab */}
<TabsContent value="browse" className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap gap-2">
<Input
placeholder="Search skills\u2026"
aria-label="Search skills"
className="max-w-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Select
value={sourceFilter ?? ""}
onValueChange={(v) => setSourceFilter(v || null)}
>
<SelectTrigger className="w-36">
<SelectValue placeholder="All sources" />
</SelectTrigger>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={categoryFilter ?? ""}
onValueChange={(v) => setCategoryFilter(v || null)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={sortBy}
onValueChange={(v) => setSortBy(v as SortBy)}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rating">Sort: Rating</SelectItem>
<SelectItem value="name">Sort: Name</SelectItem>
<SelectItem value="recent">Sort: Recent</SelectItem>
</SelectContent>
</Select>
</div>
{/* Active filter chips */}
<FilterBar
filters={activeFilters}
onRemove={handleRemoveFilter}
onClear={handleClearFilters}
/>
{/* Skill grid */}
{filteredSkills.length > 0 && (
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{filteredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
)}
{/* Browse empty state */}
{filteredSkills.length === 0 && (
<EmptyState
icon={Search}
message="No skills found"
action="Refresh registry"
onAction={() => fetchMutation.mutate()}
/>
)}
</TabsContent>
{/* Installed tab */}
<TabsContent value="installed" className="space-y-6">
{installedGroups.length === 0 && (
<EmptyState
icon={Download}
message="No skills installed"
action="Browse skills"
onAction={() => setTab("browse")}
/>
)}
{installedGroups.map((group) => (
<div key={group.agentId}>
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
<Identity name={group.agentName} size="sm" />
<span className="text-xs text-muted-foreground ml-1">{group.skills.length}</span>
</div>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-3")}>
{group.skills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled
hasUpdate={false}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</div>
))}
</TabsContent>
{/* Trending tab */}
<TabsContent value="trending" className="space-y-8">
{skills.length === 0 && (
<EmptyState
icon={TrendingUp}
message="No trending data yet"
/>
)}
{skills.length > 0 && (
<>
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Gaining Traction
</h2>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{gainingTraction.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</section>
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recently Updated
</h2>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{recentlyUpdated.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</section>
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
You Might Like
</h2>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{youMightLike.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
isInstalled={!!skill.activeVersionId}
hasUpdate={false}
onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))}
</div>
</section>
</>
)}
</TabsContent>
</Tabs>
)}
{/* Agent selector dialog (install / update) */}
<Dialog
open={!!installDialog}
onOpenChange={(open) => {
if (!open) {
setInstallDialog(null);
setAgentSkillsDir("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Select agent</DialogTitle>
<DialogDescription>
Choose which agent should receive this skill.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor="skills-dir-input">
Agent skills directory (leave blank to use default)
</label>
<Input
id="skills-dir-input"
placeholder="/path/to/agent/.claude/skills"
value={agentSkillsDir}
onChange={(e) => setAgentSkillsDir(e.target.value)}
/>
</div>
<div className="space-y-1">
{agents.length === 0 && (
<p className="text-sm text-muted-foreground">No agents found in this workspace.</p>
)}
{agents.map((agent) => (
<Button
key={agent.id}
variant="outline"
className="w-full justify-start"
disabled={installMutation.isPending || updateMutation.isPending}
onClick={() => handleInstallForAgent(agent.id)}
>
<Identity name={agent.name} size="sm" />
</Button>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setInstallDialog(null);
setAgentSkillsDir("");
}}
>
Cancel
</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={() => {
if (uninstallDialog) {
removeMutation.mutate(uninstallDialog.skillId);
setUninstallDialog(null);
}
}}
>
Yes, uninstall
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}