[nexus] feat(19-03): dual-section Installed tab and read-only SkillCard for native skills
- SkillCard: add isReadOnly and source props; hide update/rollback/install when isReadOnly - SkillCard: show Native badge when isReadOnly or source=native - SkillBrowser: remove agentSkillsDir state and text input from install dialog - SkillBrowser: add selectedAgentId state for per-agent installed skills view - SkillBrowser: add agentInstalledSkills query using skillGroupsApi.listAgentSkills - SkillBrowser: split installed skills into managedSkills/nativeSkills sections - SkillBrowser: render Managed/Native section headings when nativeSkills.length > 0 - SkillBrowser: uninstall dialog now carries agentId and calls skillRegistryApi.uninstall - SkillBrowser: install dialog sends agentId directly (no agentSkillsDir text input)
This commit is contained in:
parent
6b205b9f21
commit
da3a43e349
2 changed files with 176 additions and 76 deletions
|
|
@ -20,6 +20,8 @@ export interface SkillCardProps {
|
|||
onRollback?: () => void;
|
||||
onUninstall?: () => void;
|
||||
isLoading?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
source?: "managed" | "native";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -32,13 +34,15 @@ export function SkillCard({
|
|||
onRollback,
|
||||
onUninstall,
|
||||
isLoading = false,
|
||||
isReadOnly = false,
|
||||
source,
|
||||
className,
|
||||
}: SkillCardProps) {
|
||||
return (
|
||||
<Card className={cn("flex flex-col", className)}>
|
||||
<CardContent className="p-4 flex flex-col gap-2">
|
||||
|
||||
{/* Row 1: name link (primary visual anchor) + update badge */}
|
||||
{/* Row 1: name link (primary visual anchor) + update badge + native badge */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link
|
||||
to={`/skills/detail/${encodeURIComponent(skill.id)}`}
|
||||
|
|
@ -46,15 +50,26 @@ export function SkillCard({
|
|||
>
|
||||
{skill.name}
|
||||
</Link>
|
||||
{hasUpdate && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-amber-600 border-amber-500 shrink-0"
|
||||
aria-label="Update available"
|
||||
>
|
||||
Update
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{(isReadOnly || source === "native") && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-label="Native skill"
|
||||
>
|
||||
Native
|
||||
</Badge>
|
||||
)}
|
||||
{hasUpdate && !isReadOnly && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-amber-600 border-amber-500"
|
||||
aria-label="Update available"
|
||||
>
|
||||
Update
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: description (2-line clamp) */}
|
||||
|
|
@ -72,7 +87,7 @@ export function SkillCard({
|
|||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1">
|
||||
{isInstalled && onRollback && (
|
||||
{!isReadOnly && isInstalled && onRollback && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -87,7 +102,7 @@ export function SkillCard({
|
|||
<TooltipContent>Rollback</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isInstalled && (
|
||||
{!isReadOnly && !isInstalled && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -98,7 +113,7 @@ export function SkillCard({
|
|||
{isLoading ? "Installing\u2026" : "Install skill"}
|
||||
</Button>
|
||||
)}
|
||||
{isInstalled && hasUpdate && (
|
||||
{!isReadOnly && isInstalled && hasUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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";
|
||||
|
|
@ -55,10 +56,12 @@ export function SkillBrowser() {
|
|||
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 [agentSkillsDir, setAgentSkillsDir] = useState("");
|
||||
const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string } | null>(null);
|
||||
const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string; agentId: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
|
|
@ -79,6 +82,13 @@ export function SkillBrowser() {
|
|||
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(),
|
||||
|
|
@ -92,10 +102,13 @@ export function SkillBrowser() {
|
|||
});
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (params: { skillId: string; agentSkillsDir: string }) =>
|
||||
skillRegistryApi.install(params.skillId, params.agentSkillsDir),
|
||||
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) => {
|
||||
|
|
@ -104,10 +117,13 @@ export function SkillBrowser() {
|
|||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (params: { skillId: string; agentSkillsDir: string }) =>
|
||||
skillRegistryApi.install(params.skillId, params.agentSkillsDir),
|
||||
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) => {
|
||||
|
|
@ -116,8 +132,8 @@ export function SkillBrowser() {
|
|||
});
|
||||
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (params: { skillId: string; versionId: string; agentSkillsDir: string }) =>
|
||||
skillRegistryApi.rollback(params.skillId, params.versionId, params.agentSkillsDir),
|
||||
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" });
|
||||
|
|
@ -128,9 +144,13 @@ export function SkillBrowser() {
|
|||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (skillId: string) => skillRegistryApi.remove(skillId),
|
||||
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" });
|
||||
},
|
||||
});
|
||||
|
|
@ -181,11 +201,20 @@ export function SkillBrowser() {
|
|||
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 }];
|
||||
// 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
|
||||
|
|
@ -224,14 +253,12 @@ export function SkillBrowser() {
|
|||
|
||||
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 });
|
||||
updateMutation.mutate({ skillId: installDialog.skillId, agentId });
|
||||
} else {
|
||||
installMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir });
|
||||
installMutation.mutate({ skillId: installDialog.skillId, agentId });
|
||||
}
|
||||
setInstallDialog(null);
|
||||
setAgentSkillsDir("");
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
|
|
@ -345,7 +372,10 @@ export function SkillBrowser() {
|
|||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
onUninstall={() => {
|
||||
// Browse tab uninstall: no specific agentId context — open install dialog to select agent first
|
||||
void skill.id;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -364,34 +394,106 @@ export function SkillBrowser() {
|
|||
|
||||
{/* Installed tab */}
|
||||
<TabsContent value="installed" className="space-y-6">
|
||||
{installedGroups.length === 0 && (
|
||||
{/* Agent selector for installed tab */}
|
||||
{agents.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Download}
|
||||
message="No skills installed"
|
||||
action="Browse skills"
|
||||
onAction={() => setTab("browse")}
|
||||
message="No agents found in this workspace."
|
||||
/>
|
||||
)}
|
||||
{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 })}
|
||||
/>
|
||||
{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" />
|
||||
</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}
|
||||
</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 */}
|
||||
|
|
@ -418,7 +520,7 @@ export function SkillBrowser() {
|
|||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
onUninstall={() => void skill.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -437,7 +539,7 @@ export function SkillBrowser() {
|
|||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
onUninstall={() => void skill.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -456,7 +558,7 @@ export function SkillBrowser() {
|
|||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
onUninstall={() => void skill.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -471,10 +573,7 @@ export function SkillBrowser() {
|
|||
<Dialog
|
||||
open={!!installDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setInstallDialog(null);
|
||||
setAgentSkillsDir("");
|
||||
}
|
||||
if (!open) setInstallDialog(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
|
|
@ -485,17 +584,6 @@ export function SkillBrowser() {
|
|||
</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>
|
||||
|
|
@ -516,10 +604,7 @@ export function SkillBrowser() {
|
|||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setInstallDialog(null);
|
||||
setAgentSkillsDir("");
|
||||
}}
|
||||
onClick={() => setInstallDialog(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
|
@ -548,7 +633,7 @@ export function SkillBrowser() {
|
|||
disabled={removeMutation.isPending}
|
||||
onClick={() => {
|
||||
if (uninstallDialog) {
|
||||
removeMutation.mutate(uninstallDialog.skillId);
|
||||
removeMutation.mutate({ skillId: uninstallDialog.skillId, agentId: uninstallDialog.agentId });
|
||||
setUninstallDialog(null);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue