- 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
695 lines
26 KiB
TypeScript
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"
|
|
>
|
|
← 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'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>
|
|
);
|
|
}
|