[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;
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"

View file

@ -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"
>
&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>
{/* 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);
}
}}