[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:
Mikkel Georgsen 2026-04-01 11:38:08 +02:00 committed by Nexus Dev
parent 6b205b9f21
commit da3a43e349
2 changed files with 176 additions and 76 deletions

View file

@ -20,6 +20,8 @@ export interface SkillCardProps {
onRollback?: () => void; onRollback?: () => void;
onUninstall?: () => void; onUninstall?: () => void;
isLoading?: boolean; isLoading?: boolean;
isReadOnly?: boolean;
source?: "managed" | "native";
className?: string; className?: string;
} }
@ -32,13 +34,15 @@ export function SkillCard({
onRollback, onRollback,
onUninstall, onUninstall,
isLoading = false, isLoading = false,
isReadOnly = false,
source,
className, className,
}: SkillCardProps) { }: SkillCardProps) {
return ( return (
<Card className={cn("flex flex-col", className)}> <Card className={cn("flex flex-col", className)}>
<CardContent className="p-4 flex flex-col gap-2"> <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"> <div className="flex items-start justify-between gap-2">
<Link <Link
to={`/skills/detail/${encodeURIComponent(skill.id)}`} to={`/skills/detail/${encodeURIComponent(skill.id)}`}
@ -46,15 +50,26 @@ export function SkillCard({
> >
{skill.name} {skill.name}
</Link> </Link>
{hasUpdate && ( <div className="flex shrink-0 gap-1">
<Badge {(isReadOnly || source === "native") && (
variant="outline" <Badge
className="text-xs text-amber-600 border-amber-500 shrink-0" variant="secondary"
aria-label="Update available" className="text-xs text-muted-foreground"
> aria-label="Native skill"
Update >
</Badge> Native
)} </Badge>
)}
{hasUpdate && !isReadOnly && (
<Badge
variant="outline"
className="text-xs text-amber-600 border-amber-500"
aria-label="Update available"
>
Update
</Badge>
)}
</div>
</div> </div>
{/* Row 2: description (2-line clamp) */} {/* Row 2: description (2-line clamp) */}
@ -72,7 +87,7 @@ export function SkillCard({
</span> </span>
)} )}
<div className="ml-auto flex gap-1"> <div className="ml-auto flex gap-1">
{isInstalled && onRollback && ( {!isReadOnly && isInstalled && onRollback && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -87,7 +102,7 @@ export function SkillCard({
<TooltipContent>Rollback</TooltipContent> <TooltipContent>Rollback</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{!isInstalled && ( {!isReadOnly && !isInstalled && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -98,7 +113,7 @@ export function SkillCard({
{isLoading ? "Installing\u2026" : "Install skill"} {isLoading ? "Installing\u2026" : "Install skill"}
</Button> </Button>
)} )}
{isInstalled && hasUpdate && ( {!isReadOnly && isInstalled && hasUpdate && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"

View file

@ -9,6 +9,7 @@ import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext"; import { useToast } from "@/context/ToastContext";
import { skillRegistryApi } from "@/api/skillRegistry"; import { skillRegistryApi } from "@/api/skillRegistry";
import { skillGroupsApi } from "@/api/skillGroups";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -55,10 +56,12 @@ export function SkillBrowser() {
const [categoryFilter, setCategoryFilter] = useState<string | null>(null); const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortBy>("rating"); const [sortBy, setSortBy] = useState<SortBy>("rating");
// Installed tab: selected agent for per-agent skill view
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Dialog state // Dialog state
const [installDialog, setInstallDialog] = useState<{ skillId: string; isUpdate?: boolean } | null>(null); const [installDialog, setInstallDialog] = useState<{ skillId: string; isUpdate?: boolean } | null>(null);
const [agentSkillsDir, setAgentSkillsDir] = useState(""); const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string; agentId: string } | null>(null);
const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string } | null>(null);
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
@ -79,6 +82,13 @@ export function SkillBrowser() {
enabled: !!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 // Mutations
const fetchMutation = useMutation({ const fetchMutation = useMutation({
mutationFn: () => skillRegistryApi.fetch(), mutationFn: () => skillRegistryApi.fetch(),
@ -92,10 +102,13 @@ export function SkillBrowser() {
}); });
const installMutation = useMutation({ const installMutation = useMutation({
mutationFn: (params: { skillId: string; agentSkillsDir: string }) => mutationFn: (params: { skillId: string; agentId: string }) =>
skillRegistryApi.install(params.skillId, params.agentSkillsDir), skillRegistryApi.install(params.skillId, params.agentId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
if (selectedAgentId) {
queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] });
}
pushToast({ title: "Skill installed", tone: "success" }); pushToast({ title: "Skill installed", tone: "success" });
}, },
onError: (err: Error) => { onError: (err: Error) => {
@ -104,10 +117,13 @@ export function SkillBrowser() {
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (params: { skillId: string; agentSkillsDir: string }) => mutationFn: (params: { skillId: string; agentId: string }) =>
skillRegistryApi.install(params.skillId, params.agentSkillsDir), skillRegistryApi.install(params.skillId, params.agentId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
if (selectedAgentId) {
queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] });
}
pushToast({ title: "Skill updated", tone: "success" }); pushToast({ title: "Skill updated", tone: "success" });
}, },
onError: (err: Error) => { onError: (err: Error) => {
@ -116,8 +132,8 @@ export function SkillBrowser() {
}); });
const rollbackMutation = useMutation({ const rollbackMutation = useMutation({
mutationFn: (params: { skillId: string; versionId: string; agentSkillsDir: string }) => mutationFn: (params: { skillId: string; versionId: string; agentId: string }) =>
skillRegistryApi.rollback(params.skillId, params.versionId, params.agentSkillsDir), skillRegistryApi.rollback(params.skillId, params.versionId, params.agentId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
pushToast({ title: "Rolled back to previous version", tone: "success" }); pushToast({ title: "Rolled back to previous version", tone: "success" });
@ -128,9 +144,13 @@ export function SkillBrowser() {
}); });
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (skillId: string) => skillRegistryApi.remove(skillId), mutationFn: (params: { skillId: string; agentId: string }) =>
skillRegistryApi.uninstall(params.skillId, params.agentId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list }); queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
if (selectedAgentId) {
queryClient.invalidateQueries({ queryKey: ["agentInstalledSkills", selectedAgentId] });
}
pushToast({ title: "Skill uninstalled", tone: "success" }); pushToast({ title: "Skill uninstalled", tone: "success" });
}, },
}); });
@ -181,11 +201,20 @@ export function SkillBrowser() {
if (key === "category") setCategoryFilter(null); if (key === "category") setCategoryFilter(null);
}; };
// Installed tab grouping // Installed tab: split into managed/native
const installedGroups = useMemo(() => { const managedSkills = useMemo(
const installed = skills.filter((s) => s.activeVersionId && !s.removedAt); () => agentInstalledSkills.filter((s) => s.source === "managed"),
if (installed.length === 0) return []; [agentInstalledSkills],
return [{ agentId: "all", agentName: "All Agents", skills: installed }]; );
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]); }, [skills]);
// Trending tab sections // Trending tab sections
@ -224,14 +253,12 @@ export function SkillBrowser() {
const handleInstallForAgent = (agentId: string) => { const handleInstallForAgent = (agentId: string) => {
if (!installDialog) return; if (!installDialog) return;
const dir = agentSkillsDir.trim() || `/agents/${agentId}/.claude/skills`;
if (installDialog.isUpdate) { if (installDialog.isUpdate) {
updateMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir }); updateMutation.mutate({ skillId: installDialog.skillId, agentId });
} else { } else {
installMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir }); installMutation.mutate({ skillId: installDialog.skillId, agentId });
} }
setInstallDialog(null); setInstallDialog(null);
setAgentSkillsDir("");
}; };
const tabItems = [ const tabItems = [
@ -345,7 +372,10 @@ export function SkillBrowser() {
onInstall={() => setInstallDialog({ skillId: skill.id })} onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)} 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> </div>
@ -364,34 +394,106 @@ export function SkillBrowser() {
{/* Installed tab */} {/* Installed tab */}
<TabsContent value="installed" className="space-y-6"> <TabsContent value="installed" className="space-y-6">
{installedGroups.length === 0 && ( {/* Agent selector for installed tab */}
{agents.length === 0 && (
<EmptyState <EmptyState
icon={Download} icon={Download}
message="No skills installed" message="No agents found in this workspace."
action="Browse skills"
onAction={() => setTab("browse")}
/> />
)} )}
{installedGroups.map((group) => ( {agents.length > 0 && !selectedAgentId && (
<div key={group.agentId}> <div className="space-y-2">
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md"> <p className="text-sm text-muted-foreground">Select an agent to view installed skills:</p>
<Identity name={group.agentName} size="sm" /> <div className="flex flex-wrap gap-2">
<span className="text-xs text-muted-foreground ml-1">{group.skills.length}</span> {agents.map((agent) => (
</div> <Button
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-3")}> key={agent.id}
{group.skills.map((skill) => ( variant="outline"
<SkillCard size="sm"
key={skill.id} onClick={() => setSelectedAgentId(agent.id)}
skill={skill} >
isInstalled <Identity name={agent.name} size="sm" />
hasUpdate={false} </Button>
onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
/>
))} ))}
</div> </div>
</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}
</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> </TabsContent>
{/* Trending tab */} {/* Trending tab */}
@ -418,7 +520,7 @@ export function SkillBrowser() {
onInstall={() => setInstallDialog({ skillId: skill.id })} onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)} onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })} onUninstall={() => void skill.id}
/> />
))} ))}
</div> </div>
@ -437,7 +539,7 @@ export function SkillBrowser() {
onInstall={() => setInstallDialog({ skillId: skill.id })} onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)} onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })} onUninstall={() => void skill.id}
/> />
))} ))}
</div> </div>
@ -456,7 +558,7 @@ export function SkillBrowser() {
onInstall={() => setInstallDialog({ skillId: skill.id })} onInstall={() => setInstallDialog({ skillId: skill.id })}
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })} onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
onRollback={() => handleRollback(skill.id)} onRollback={() => handleRollback(skill.id)}
onUninstall={() => setUninstallDialog({ skillId: skill.id })} onUninstall={() => void skill.id}
/> />
))} ))}
</div> </div>
@ -471,10 +573,7 @@ export function SkillBrowser() {
<Dialog <Dialog
open={!!installDialog} open={!!installDialog}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) setInstallDialog(null);
setInstallDialog(null);
setAgentSkillsDir("");
}
}} }}
> >
<DialogContent> <DialogContent>
@ -485,17 +584,6 @@ export function SkillBrowser() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <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"> <div className="space-y-1">
{agents.length === 0 && ( {agents.length === 0 && (
<p className="text-sm text-muted-foreground">No agents found in this workspace.</p> <p className="text-sm text-muted-foreground">No agents found in this workspace.</p>
@ -516,10 +604,7 @@ export function SkillBrowser() {
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => setInstallDialog(null)}
setInstallDialog(null);
setAgentSkillsDir("");
}}
> >
Cancel Cancel
</Button> </Button>
@ -548,7 +633,7 @@ export function SkillBrowser() {
disabled={removeMutation.isPending} disabled={removeMutation.isPending}
onClick={() => { onClick={() => {
if (uninstallDialog) { if (uninstallDialog) {
removeMutation.mutate(uninstallDialog.skillId); removeMutation.mutate({ skillId: uninstallDialog.skillId, agentId: uninstallDialog.agentId });
setUninstallDialog(null); setUninstallDialog(null);
} }
}} }}