nexus/ui/src/pages/SkillBrowser.tsx
Mikkel Georgsen f79e0aa628 feat(20-02): add adapter labels and unsupported install guard to SkillBrowser
- Import getUIAdapter and resolveAdapterSkillConfig/listAdapterSkillConfigs
- Show adapter type label in parentheses next to agent name in Installed tab selector
- Show adapter type label in install dialog agent list
- Guard handleInstallForAgent: show unsupportedMessage if adapter does not support install
- Dismissible error alert in install dialog for unsupported adapter attempts
- Clear unsupportedMessage on dialog open/close
- Compute COMPATIBLE_ADAPTER_LABELS at module level for Task 2
2026-04-02 15:08:50 +00:00

695 lines
26 KiB
TypeScript

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 { skillGroupsApi } from "@/api/skillGroups";
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";
import { getUIAdapter } from "@/adapters";
import { resolveAdapterSkillConfig, listAdapterSkillConfigs } from "@paperclipai/adapter-utils";
// Compute compatible adapter labels once at module level (used by Browse/Trending SkillCards)
const COMPATIBLE_ADAPTER_LABELS = listAdapterSkillConfigs()
.filter((c) => c.supportsInstall)
.map((c) => getUIAdapter(c.adapterType).label);
type SortBy = "rating" | "name" | "recent";
export function SkillBrowser() {
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");
// Installed tab: selected agent for per-agent skill view
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Dialog state
const [installDialog, setInstallDialog] = useState<{ skillId: string; isUpdate?: boolean } | null>(null);
const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string; agentId: string } | null>(null);
const [unsupportedMessage, setUnsupportedMessage] = useState<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,
});
// Per-agent installed skills (for Installed tab)
const { data: agentInstalledSkills = [] } = useQuery({
queryKey: ["agentInstalledSkills", selectedAgentId],
queryFn: () => skillGroupsApi.listAgentSkills(selectedAgentId!),
enabled: tab === "installed" && !!selectedAgentId,
});
// 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; agentId: string }) =>
skillRegistryApi.install(params.skillId, params.agentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
if (selectedAgentId) {
queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] });
}
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; agentId: string }) =>
skillRegistryApi.install(params.skillId, params.agentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
if (selectedAgentId) {
queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] });
}
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; agentId: string }) =>
skillRegistryApi.rollback(params.skillId, params.versionId, params.agentId),
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: (params: { skillId: string; agentId: string }) =>
skillRegistryApi.uninstall(params.skillId, params.agentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
if (selectedAgentId) {
queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] });
}
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: split into managed/native
const managedSkills = useMemo(
() => agentInstalledSkills.filter((s) => s.source === "managed"),
[agentInstalledSkills],
);
const nativeSkills = useMemo(
() => agentInstalledSkills.filter((s) => s.source === "native"),
[agentInstalledSkills],
);
// Helper: get SkillListItem by skillId (for rendering in Installed tab)
const skillById = useMemo(() => {
const map = new Map(skills.map((s) => [s.id, s]));
return map;
}, [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 agent = agents.find((a) => a.id === agentId);
if (agent) {
const cfg = resolveAdapterSkillConfig(agent.adapterType ?? "process");
if (!cfg.supportsInstall) {
setUnsupportedMessage(
cfg.unsupportedReason ?? "This adapter does not support skill installation."
);
return;
}
}
if (installDialog.isUpdate) {
updateMutation.mutate({ skillId: installDialog.skillId, agentId });
} else {
installMutation.mutate({ skillId: installDialog.skillId, agentId });
}
setInstallDialog(null);
};
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={() => {
// Browse tab uninstall: no specific agentId context — open install dialog to select agent first
void 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">
{/* Agent selector for installed tab */}
{agents.length === 0 && (
<EmptyState
icon={Download}
message="No agents found in this workspace."
/>
)}
{agents.length > 0 && !selectedAgentId && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Select an agent to view installed skills:</p>
<div className="flex flex-wrap gap-2">
{agents.map((agent) => (
<Button
key={agent.id}
variant="outline"
size="sm"
onClick={() => setSelectedAgentId(agent.id)}
>
<Identity name={agent.name} size="sm" />
<span className="text-muted-foreground ml-1 text-xs">
({getUIAdapter(agent.adapterType ?? "process").label})
</span>
</Button>
))}
</div>
</div>
)}
{selectedAgentId && (
<div className="space-y-4">
{/* Back to agent selector */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedAgentId(null)}
className="text-xs"
>
&larr; All agents
</Button>
<span className="text-sm font-medium">
{agents.find((a) => a.id === selectedAgentId)?.name ?? selectedAgentId}
{(() => {
const a = agents.find((ag) => ag.id === selectedAgentId);
return a ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
({getUIAdapter(a.adapterType ?? "process").label})
</span>
) : null;
})()}
</span>
</div>
{agentInstalledSkills.length === 0 && (
<EmptyState
icon={Download}
message="No skills installed for this agent"
action="Browse skills"
onAction={() => setTab("browse")}
/>
)}
{/* Managed skills section */}
{managedSkills.length > 0 && (
<>
{nativeSkills.length > 0 && (
<h3 className="text-sm font-medium text-muted-foreground mt-4 mb-2">Managed</h3>
)}
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{managedSkills.map((entry) => {
const skill = skillById.get(entry.skillId);
if (!skill) return null;
return (
<SkillCard
key={entry.skillId}
skill={skill}
isInstalled
hasUpdate={false}
source="managed"
onRollback={() => handleRollback(entry.skillId)}
onUninstall={() => setUninstallDialog({ skillId: entry.skillId, agentId: selectedAgentId })}
/>
);
})}
</div>
</>
)}
{/* Native skills section */}
{nativeSkills.length > 0 && (
<>
<h3 className="text-sm font-medium text-muted-foreground mt-4 mb-2">Native</h3>
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
{nativeSkills.map((entry) => {
const skill = skillById.get(entry.skillId);
if (!skill) return null;
return (
<SkillCard
key={entry.skillId}
skill={skill}
isInstalled
hasUpdate={false}
isReadOnly={true}
source="native"
/>
);
})}
</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={() => void 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={() => void 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={() => void skill.id}
/>
))}
</div>
</section>
</>
)}
</TabsContent>
</Tabs>
)}
{/* Agent selector dialog (install / update) */}
<Dialog
open={!!installDialog}
onOpenChange={(open) => {
if (!open) {
setInstallDialog(null);
setUnsupportedMessage(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Select agent</DialogTitle>
<DialogDescription>
Choose which agent should receive this skill.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{unsupportedMessage && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm">
<p className="font-medium text-destructive">Cannot install on this agent</p>
<p className="text-muted-foreground mt-1">{unsupportedMessage}</p>
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setUnsupportedMessage(null)}>
Dismiss
</Button>
</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" />
<span className="text-muted-foreground ml-1 text-xs">
({getUIAdapter(agent.adapterType ?? "process").label})
</span>
</Button>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setInstallDialog(null);
setUnsupportedMessage(null);
}}
>
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({ skillId: uninstallDialog.skillId, agentId: uninstallDialog.agentId });
setUninstallDialog(null);
}
}}
>
Yes, uninstall
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}