diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 1ce25503..996fe9aa 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -1,9 +1,14 @@ import type { + CatalogSkill, + CatalogSkillFileDetail, + CatalogSkillKind, CompanySkill, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, CompanySkillImportResult, + CompanySkillInstallCatalogRequest, + CompanySkillInstallCatalogResult, CompanySkillListItem, CompanySkillProjectScanRequest, CompanySkillProjectScanResult, @@ -11,6 +16,12 @@ import type { } from "@paperclipai/shared"; import { api } from "./client"; +export interface CatalogListQuery { + kind?: CatalogSkillKind; + category?: string; + q?: string; +} + export const companySkillsApi = { list: (companyId: string) => api.get(`/companies/${encodeURIComponent(companyId)}/skills`), @@ -36,15 +47,10 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills`, payload, ), - importFromSource: (companyId: string, source: string, authToken?: string) => + importFromSource: (companyId: string, source: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, - { source, ...(authToken ? { authToken } : {}) }, - ), - updateAuth: (companyId: string, skillId: string, authToken: string | null) => - api.patch( - `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`, - { authToken }, + { source }, ), scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => api.post( @@ -60,4 +66,23 @@ export const companySkillsApi = { api.delete( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, ), + catalogList: (query: CatalogListQuery = {}) => { + const params = new URLSearchParams(); + if (query.kind) params.set("kind", query.kind); + if (query.category) params.set("category", query.category); + if (query.q) params.set("q", query.q); + const search = params.toString(); + return api.get(`/skills/catalog${search ? `?${search}` : ""}`); + }, + catalogDetail: (catalogRef: string) => + api.get(`/skills/catalog/${encodeURIComponent(catalogRef)}`), + catalogFile: (catalogRef: string, relativePath: string = "SKILL.md") => + api.get( + `/skills/catalog/${encodeURIComponent(catalogRef)}/files?path=${encodeURIComponent(relativePath)}`, + ), + installCatalog: (companyId: string, payload: CompanySkillInstallCatalogRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/install-catalog`, + payload, + ), }; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 9d0e9476..100adf8c 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -11,6 +11,11 @@ export const queryKeys = { ["company-skills", companyId, skillId, "update-status"] as const, file: (companyId: string, skillId: string, relativePath: string) => ["company-skills", companyId, skillId, "file", relativePath] as const, + catalog: (filters: { kind?: string; category?: string; q?: string } = {}) => + ["company-skills", "catalog", filters.kind ?? "__all-kinds__", filters.category ?? "__all-categories__", filters.q ?? ""] as const, + catalogDetail: (catalogRef: string) => ["company-skills", "catalog", "detail", catalogRef] as const, + catalogFile: (catalogRef: string, relativePath: string) => + ["company-skills", "catalog", "file", catalogRef, relativePath] as const, }, agents: { list: (companyId: string) => ["agents", companyId] as const, @@ -63,6 +68,8 @@ export const queryKeys = { documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const, + documentAnnotations: (issueId: string, key: string, status: "open" | "resolved" | "all" = "all") => + ["issues", "document-annotations", issueId, key, status] as const, activity: (issueId: string) => ["issues", "activity", issueId] as const, runs: (issueId: string) => ["issues", "runs", issueId] as const, approvals: (issueId: string) => ["issues", "approvals", issueId] as const, diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index ed6429be..ff2d3095 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -1,7 +1,11 @@ import { useEffect, useMemo, useState, type SVGProps } from "react"; -import { Link, useNavigate, useParams } from "@/lib/router"; +import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + Agent, + CatalogSkill, + CatalogSkillFileDetail, + CompanySkillCompatibility, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, @@ -9,9 +13,11 @@ import type { CompanySkillListItem, CompanySkillProjectScanResult, CompanySkillSourceBadge, + CompanySkillTrustLevel, CompanySkillUpdateStatus, } from "@paperclipai/shared"; import { companySkillsApi } from "../api/companySkills"; +import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; @@ -22,6 +28,7 @@ import { MarkdownEditor } from "../components/MarkdownEditor"; import { PageSkeleton } from "../components/PageSkeleton"; import { CopyText } from "../components/CopyText"; import { Identity } from "../components/Identity"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { Dialog, DialogContent, @@ -31,21 +38,45 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { + AlertTriangle, + ArrowUpCircle, Boxes, + Check, ChevronDown, ChevronRight, Code2, + Download, Eye, + Filter, FileCode2, FileText, Folder, FolderOpen, Github, + Globe, + HelpCircle, Link2, ExternalLink, Paperclip, @@ -57,6 +88,8 @@ import { Search, ShieldCheck, Trash2, + Users, + XOctagon, } from "lucide-react"; type SkillTreeNode = { @@ -242,6 +275,10 @@ function skillRoute(skillId: string, filePath?: string | null) { return filePath ? `/skills/${skillId}/files/${encodeSkillFilePath(filePath)}` : `/skills/${skillId}`; } +function catalogSkillRoute(catalogRef: string) { + return `/skills?view=catalog&catalog=${encodeURIComponent(catalogRef)}`; +} + function parentDirectoryPaths(filePath: string) { const segments = filePath.split("/").filter(Boolean); const parents: string[] = []; @@ -251,6 +288,237 @@ function parentDirectoryPaths(filePath: string) { return parents; } +type SourceFilter = "all" | "company" | "bundled" | "optional" | "external"; + +const SOURCE_FILTER_LABELS: Record = { + all: "All", + company: "Company", + bundled: "Bundled", + optional: "Optional", + external: "External", +}; + +function readonlyMetadataValue(metadata: Record | null | undefined, key: string): string | null { + if (!metadata || typeof metadata !== "object") return null; + const raw = (metadata as Record)[key]; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readonlyMetadataKind(metadata: Record | null | undefined): "bundled" | "optional" | null { + const value = readonlyMetadataValue(metadata, "sourceKind") ?? readonlyMetadataValue(metadata, "catalogKind"); + if (value === "bundled") return "bundled"; + if (value === "optional") return "optional"; + return null; +} + +function classifySource(skill: { + sourceBadge: CompanySkillSourceBadge; + sourceType: string; + catalogKind?: "bundled" | "optional" | null; + metadata?: Record | null; +}): SourceFilter { + if (skill.sourceBadge === "paperclip") return "company"; + if (skill.sourceType === "local_path" && !skill.sourceBadge.toString().includes("github")) { + return "company"; + } + if (skill.sourceType === "catalog" || skill.sourceBadge === "catalog") { + const kind = skill.catalogKind ?? readonlyMetadataKind(skill.metadata); + if (kind === "bundled") return "bundled"; + if (kind === "optional") return "optional"; + return "company"; + } + if (skill.sourceBadge === "github" || skill.sourceBadge === "skills_sh" || skill.sourceBadge === "url" || skill.sourceBadge === "local") { + return "external"; + } + return "company"; +} + +function SourceFilterMenu({ + counts, + value, + onChange, +}: { + counts: Record; + value: SourceFilter; + onChange: (next: SourceFilter) => void; +}) { + const filters: SourceFilter[] = ["all", "company", "bundled", "optional", "external"]; + const activeFilterCount = value === "all" ? 0 : 1; + return ( + + + + + + Source + onChange(next as SourceFilter)}> + {filters.map((filter) => ( + + {SOURCE_FILTER_LABELS[filter]} + {counts[filter] ?? 0} + + ))} + + + + ); +} + +function CatalogFilterMenu({ + kindFilter, + categoryFilter, + categories, + onKindChange, + onCategoryChange, +}: { + kindFilter: "all" | "bundled" | "optional"; + categoryFilter: string; + categories: string[]; + onKindChange: (next: "all" | "bundled" | "optional") => void; + onCategoryChange: (next: string) => void; +}) { + const activeFilterCount = (kindFilter === "all" ? 0 : 1) + (categoryFilter ? 1 : 0); + return ( + + + + + + Type + onKindChange(next as "all" | "bundled" | "optional")}> + All + Bundled + Optional + + + Category + onCategoryChange(next === "__all__" ? "" : next)}> + All categories + {categories.map((category) => ( + + {category} + + ))} + + + + ); +} + +function TrustChip({ level }: { level: CompanySkillTrustLevel }) { + const map = { + markdown_only: { + icon: ShieldCheck, + label: "Markdown only", + tooltip: "Text only — no scripts, no binaries, no assets.", + className: "border-border bg-muted/40 text-muted-foreground", + }, + assets: { + icon: Folder, + label: "Includes assets", + tooltip: "Ships images, fonts, or other non-script files.", + className: "border-cyan-500/30 bg-cyan-500/10 text-cyan-200", + }, + scripts_executables: { + icon: AlertTriangle, + label: "Includes scripts", + tooltip: "Ships executable scripts. Review before installing.", + className: "border-amber-500/40 bg-amber-500/10 text-amber-200", + }, + } as const; + const config = map[level] ?? map.markdown_only; + const Icon = config.icon; + return ( + + + + + + {config.tooltip} + + ); +} + +function CompatChip({ compatibility }: { compatibility: CompanySkillCompatibility }) { + if (compatibility === "compatible") return null; + const map = { + unknown: { + icon: HelpCircle, + label: "Unknown format", + tooltip: "Paperclip could not validate this skill as Agent Skills markdown. Install at your own risk.", + className: "border-yellow-500/40 bg-yellow-500/10 text-yellow-200", + }, + invalid: { + icon: XOctagon, + label: "Invalid", + tooltip: "This skill cannot be installed — content is not valid Agent Skills markdown.", + className: "border-destructive/40 bg-destructive/10 text-destructive", + }, + } as const; + const config = map[compatibility]; + const Icon = config.icon; + return ( + + + + + + {config.tooltip} + + ); +} + +function ProvenanceBadge({ packageName, packageVersion }: { packageName: string | null; packageVersion: string | null }) { + if (!packageName) return null; + return ( + + + + + + Installed from the app-shipped skills catalog. Provenance is signed by package version and content hash. + + ); +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + function NewSkillForm({ onCreate, isPending, @@ -302,6 +570,589 @@ function NewSkillForm({ ); } +function CatalogList({ + skills, + kindFilter, + categoryFilter, + catalogFilter, + installedByKey, + selectedCatalogRef, + selectedPath, + expandedSkillId, + expandedDirs, + onSelect, + onSelectPath, + onToggleSkill, + onToggleDir, +}: { + skills: CatalogSkill[]; + kindFilter: "all" | "bundled" | "optional"; + categoryFilter: string; + catalogFilter: string; + installedByKey: Map; + selectedCatalogRef: string | null; + selectedPath: string; + expandedSkillId: string | null; + expandedDirs: Record>; + onSelect: (catalogRef: string) => void; + onSelectPath: (catalogRef: string, path: string) => void; + onToggleSkill: (catalogRef: string) => void; + onToggleDir: (catalogRef: string, path: string) => void; +}) { + const lowered = catalogFilter.trim().toLowerCase(); + const filtered = skills.filter((skill) => { + if (kindFilter !== "all" && skill.kind !== kindFilter) return false; + if (categoryFilter && skill.category !== categoryFilter) return false; + if (!lowered) return true; + const haystack = `${skill.name} ${skill.slug} ${skill.key} ${skill.description} ${skill.category} ${skill.tags.join(" ")} ${skill.recommendedForRoles.join(" ")}`.toLowerCase(); + return haystack.includes(lowered); + }); + + if (filtered.length === 0) { + return ( +
+ No catalog skills match this filter. +
+ ); + } + + const available = filtered.filter((skill) => !installedByKey.has(skill.key)); + const installed = filtered.filter((skill) => installedByKey.has(skill.key)); + const bundled = available.filter((skill) => skill.kind === "bundled"); + const optional = available.filter((skill) => skill.kind === "optional"); + + function renderRow(skill: CatalogSkill) { + const isSelected = selectedCatalogRef === skill.id || selectedCatalogRef === skill.key; + const expanded = expandedSkillId === skill.id; + const tree = buildTree(skill.files.map((file) => ({ + path: file.path, + kind: file.kind, + }))); + return ( +
+
+ onSelect(skill.id)} + > + + + + + {skill.name} + + + + +
+
+
+ ()} + onToggleDir={(path) => onToggleDir(skill.id, path)} + onSelectPath={(path) => onSelectPath(skill.id, path)} + fileHref={(skillId) => catalogSkillRoute(skillId)} + depth={1} + /> +
+
+
+ ); + } + + return ( +
+ {bundled.length > 0 && kindFilter !== "optional" ? ( +
+
+ Bundled · {bundled.length} +
+ {bundled.map(renderRow)} +
+ ) : null} + {optional.length > 0 && kindFilter !== "bundled" ? ( +
+
+ Optional · {optional.length} +
+ {optional.map(renderRow)} +
+ ) : null} + {installed.length > 0 ? ( +
+
+ Installed · {installed.length} +
+ {installed.map(renderRow)} +
+ ) : null} +
+ ); +} + +function CatalogDetailPane({ + skill, + packageName, + packageVersion, + installedSkill, + installedSkillId, + fileQuery, + selectedPath, + onInstall, + onUpdate, + onOpenInstalled, + loadingPrimaryAction, +}: { + skill: CatalogSkill | null; + packageName: string | null; + packageVersion: string | null; + installedSkill: CompanySkillListItem | null; + installedSkillId: string | null; + fileQuery: { data: CatalogSkillFileDetail | undefined; isLoading: boolean; error: unknown }; + selectedPath: string; + onInstall: () => void; + onUpdate: () => void; + onOpenInstalled: (skillId: string) => void; + loadingPrimaryAction: boolean; +}) { + if (!skill) { + return ; + } + + const installedHash = installedSkill?.originHash ?? null; + const hashOutOfSync = Boolean(installedSkill && installedHash && installedHash !== skill.contentHash); + const isInstalled = Boolean(installedSkill); + + let cta: React.ReactNode; + if (skill.compatibility === "invalid") { + cta = ( + + + + + + + This skill cannot be installed — its content is not valid Agent Skills markdown. + + ); + } else if (!isInstalled) { + cta = ( + + ); + } else if (hashOutOfSync) { + cta = ( + + ); + } else { + cta = ( + + ); + } + + const body = fileQuery.data?.markdown ? stripFrontmatter(fileQuery.data.content) : fileQuery.data?.content ?? ""; + + return ( +
+
+
+
+

+

+

{skill.description}

+
+ {skill.kind} + · + {skill.category} + · + +
+
+
{cta}
+
+ +
+ + + {hashOutOfSync ? ( + + + + + + Catalog content hash has changed since this skill was installed. + + ) : null} + {skill.requires.length > 0 ? ( + + Requires: {skill.requires.join(", ")} + + ) : null} + {skill.recommendedForRoles.length > 0 ? ( + + Roles: {skill.recommendedForRoles.join(" · ")} + + ) : null} + {skill.tags.length > 0 ? ( + + Tags: {skill.tags.join(" · ")} + + ) : null} +
+ +
+ Key + {skill.key} + · + Hash + {skill.contentHash.slice(0, 24)}… + + + +
+
+ +
+
{selectedPath}
+
+ +
+ {fileQuery.isLoading ? ( + + ) : fileQuery.error ? ( +
{fileQuery.error instanceof Error ? fileQuery.error.message : "Failed to load file"}
+ ) : !fileQuery.data ? ( +
Select a file to inspect.
+ ) : fileQuery.data.markdown ? ( + {body} + ) : ( +
+            {fileQuery.data.content}
+          
+ )} +
+
+ ); +} + +function InstallPreviewDialog({ + open, + onOpenChange, + skill, + packageName, + packageVersion, + conflict, + defaultSlug, + defaultForce, + defaultAction, + isPending, + error, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + skill: CatalogSkill | null; + packageName: string | null; + packageVersion: string | null; + conflict: CompanySkillListItem | null; + defaultSlug: string | null; + defaultForce: boolean; + defaultAction: "install" | "update" | "replace"; + isPending: boolean; + error: string | null; + onConfirm: (input: { slug: string | null; force: boolean }) => void; +}) { + const [slug, setSlug] = useState(""); + const [force, setForce] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + + useEffect(() => { + if (!open) return; + setSlug(defaultSlug ?? ""); + setForce(defaultForce); + setAdvancedOpen(defaultAction === "replace" || defaultForce); + }, [open, defaultSlug, defaultForce, defaultAction]); + + if (!skill) return null; + + let confirmLabel = "Install skill"; + let confirmVariant: "default" | "destructive" = "default"; + if (defaultAction === "update") { + confirmLabel = "Install update"; + } else if (defaultAction === "replace") { + confirmLabel = "Replace existing skill"; + confirmVariant = "destructive"; + } + if (isPending) confirmLabel = "Installing…"; + + return ( + (!isPending ? onOpenChange(value) : null)}> + + + + {defaultAction === "update" ? "Update" : defaultAction === "replace" ? "Replace" : "Install"} · {skill.name} + + + {skill.kind} · {skill.category} + {packageName ? <> · {packageName}{packageVersion ? ` v${packageVersion}` : ""} : null} + + + +
+
+
+
Trust
+
+ + {skill.trustLevel === "markdown_only" ? ( + Safe + ) : skill.trustLevel === "scripts_executables" ? ( + Review required + ) : ( + Non-script assets + )} +
+
Compatibility
+
+ {skill.compatibility === "compatible" ? ( + + + ) : ( + + )} +
+
Requires
+
{skill.requires.length === 0 ? "none" : skill.requires.join(", ")}
+
Roles
+
{skill.recommendedForRoles.length === 0 ? "any" : skill.recommendedForRoles.join(" · ")}
+
Provenance
+
+
{packageName ?? "—"}{packageVersion ? ` v${packageVersion}` : ""}
+
{skill.contentHash}
+
+
+
+ +
+
+ Files ({skill.files.length}) +
+
+ {skill.files.map((file) => ( +
+ {file.path} + {file.kind} + {formatBytes(file.sizeBytes)} +
+ ))} +
+
+ + {conflict ? ( +
+ An existing skill with key {conflict.key} is installed ( + {conflict.sourceLabel ?? conflict.sourceType}). Installing will {defaultAction === "update" ? "overwrite the catalog content" : "replace the existing skill"}. +
+ ) : null} + + + {advancedOpen ? ( +
+
+ + setSlug(event.target.value)} placeholder={defaultSlug ?? skill.slug} className="h-8" /> +
+ +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} +
+ + + + + +
+
+ ); +} + +function AttachAgentsPopover({ + open, + onOpenChange, + agents, + attachedAgentIds, + pending, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + agents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>; + attachedAgentIds: string[]; + pending: boolean; + onSubmit: (nextIds: string[]) => void; +}) { + const [filter, setFilter] = useState(""); + const [draft, setDraft] = useState>(new Set(attachedAgentIds)); + + useEffect(() => { + if (open) { + setDraft(new Set(attachedAgentIds)); + setFilter(""); + } + }, [open, attachedAgentIds]); + + const filtered = agents.filter((agent) => agent.name.toLowerCase().includes(filter.toLowerCase())); + const eligible = agents.filter((agent) => agent.supportsSkills); + + return ( + + + + + +
+ setFilter(event.target.value)} + placeholder="Filter agents" + className="h-8" + /> +
+ {eligible.length === 0 ? ( +
+ No agents in this company support skills yet. +
+ ) : ( +
+ {filtered.map((agent) => { + const disabled = agent.required || !agent.supportsSkills; + const checked = draft.has(agent.id); + return ( + + ); + })} + {filtered.length === 0 ? ( +
No matches.
+ ) : null} +
+ )} +
+ + +
+
+
+ ); +} + function SkillTree({ nodes, skillId, @@ -309,6 +1160,7 @@ function SkillTree({ expandedDirs, onToggleDir, onSelectPath, + fileHref = (currentSkillId, path) => skillRoute(currentSkillId, path), depth = 0, }: { nodes: SkillTreeNode[]; @@ -317,6 +1169,7 @@ function SkillTree({ expandedDirs: Set; onToggleDir: (path: string) => void; onSelectPath: (path: string) => void; + fileHref?: (skillId: string, path: string) => string; depth?: number; }) { return ( @@ -359,6 +1212,7 @@ function SkillTree({ expandedDirs={expandedDirs} onToggleDir={onToggleDir} onSelectPath={onSelectPath} + fileHref={fileHref} depth={depth + 1} /> )} @@ -376,7 +1230,7 @@ function SkillTree({ node.path === selectedPath && "text-foreground", )} style={{ paddingInlineStart: `${SKILL_TREE_BASE_INDENT + depth * SKILL_TREE_STEP_INDENT}px` }} - to={skillRoute(skillId, node.path)} + to={node.path ? fileHref(skillId, node.path) : skillRoute(skillId)} onClick={() => node.path && onSelectPath(node.path)} > @@ -394,6 +1248,7 @@ function SkillList({ skills, selectedSkillId, skillFilter, + sourceFilter, expandedSkillId, expandedDirs, selectedPaths, @@ -401,10 +1256,12 @@ function SkillList({ onToggleDir, onSelectSkill, onSelectPath, + onClearFilters, }: { skills: CompanySkillListItem[]; selectedSkillId: string | null; skillFilter: string; + sourceFilter: SourceFilter; expandedSkillId: string | null; expandedDirs: Record>; selectedPaths: Record; @@ -412,13 +1269,27 @@ function SkillList({ onToggleDir: (skillId: string, path: string) => void; onSelectSkill: (skillId: string) => void; onSelectPath: (skillId: string, path: string) => void; + onClearFilters: () => void; }) { const filteredSkills = skills.filter((skill) => { const haystack = `${skill.name} ${skill.key} ${skill.slug} ${skill.sourceLabel ?? ""}`.toLowerCase(); - return haystack.includes(skillFilter.toLowerCase()); + if (!haystack.includes(skillFilter.toLowerCase())) return false; + if (sourceFilter === "all") return true; + const skillSource = classifySource(skill); + return skillSource === sourceFilter; }); if (filteredSkills.length === 0) { + if (sourceFilter !== "all" && skills.length > 0) { + return ( +
+ No {SOURCE_FILTER_LABELS[sourceFilter].toLowerCase()} skills installed.{" "} + +
+ ); + } return (
No skills match this filter. @@ -497,103 +1368,6 @@ function SkillList({ ); } -function SkillAuthSection({ - companyId, - skillId, - hasAuth, -}: { - companyId: string; - skillId: string; - hasAuth: boolean; -}) { - const queryClient = useQueryClient(); - const { pushToast } = useToastActions(); - const [editing, setEditing] = useState(false); - const [token, setToken] = useState(""); - - const updateAuth = useMutation({ - mutationFn: (authToken: string | null) => - companySkillsApi.updateAuth(companyId, skillId, authToken), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) }); - setEditing(false); - setToken(""); - pushToast({ tone: "success", title: "Auth updated" }); - }, - onError: (error) => { - pushToast({ - tone: "error", - title: "Failed to update auth", - body: error instanceof Error ? error.message : "Unknown error", - }); - }, - }); - - return ( -
- Auth - {!editing ? ( - <> - {hasAuth ? ( - <> - - - - ) : ( - - )} - - ) : ( - <> - setToken(e.target.value)} - placeholder="GitHub Personal Access Token" - className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50" - autoComplete="off" - autoFocus - /> - - - - )} -
- ); -} - function SkillPane({ loading, detail, @@ -615,6 +1389,11 @@ function SkillPane({ deletePending, onSave, savePending, + attachAgents, + attachPopoverOpen, + setAttachPopoverOpen, + onSubmitAttach, + attachPending, }: { loading: boolean; detail: CompanySkillDetail | null | undefined; @@ -636,6 +1415,11 @@ function SkillPane({ deletePending: boolean; onSave: () => void; savePending: boolean; + attachAgents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>; + attachPopoverOpen: boolean; + setAttachPopoverOpen: (open: boolean) => void; + onSubmitAttach: (ids: string[]) => void; + attachPending: boolean; }) { if (!detail) { if (loading) { @@ -728,13 +1512,6 @@ function SkillPane({ )}
- {(detail.sourceType === "github" || detail.sourceType === "skills_sh") && ( - | null)?.sourceAuthSecretId)} - /> - )} {detail.sourceType === "github" && (
Pin @@ -778,8 +1555,39 @@ function SkillPane({ {detail.editable ? "Editable" : "Read only"}
-
- Used by +
+ Trust + + + {readonlyMetadataValue(detail.metadata, "userModifiedAt") ? ( + + + + + + You have edited this skill after installing. Updates from the catalog will overwrite your changes. + + ) : null} + {(() => { + const packageName = readonlyMetadataValue(detail.metadata, "originPackageName") ?? readonlyMetadataValue(detail.metadata, "catalogPackageName"); + const packageVersion = readonlyMetadataValue(detail.metadata, "originVersion") ?? readonlyMetadataValue(detail.metadata, "catalogPackageVersion"); + return ; + })()} +
+
+
+ Used by + agent.id)} + pending={attachPending} + onSubmit={onSubmitAttach} + /> +
{usedBy.length === 0 ? ( No agents attached ) : ( @@ -878,12 +1686,13 @@ export function CompanySkills() { const { "*": routePath } = useParams<{ "*": string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToastActions(); + const adapterCaps = useAdapterCapabilities(); const [skillFilter, setSkillFilter] = useState(""); const [source, setSource] = useState(""); - const [importAuthToken, setImportAuthToken] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false); const [expandedSkillId, setExpandedSkillId] = useState(null); @@ -897,9 +1706,60 @@ export function CompanySkills() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteTargetSkillId, setDeleteTargetSkillId] = useState(null); const [deleteTargetDetail, setDeleteTargetDetail] = useState(null); + const [catalogFilter, setCatalogFilter] = useState(""); + const [catalogKindFilter, setCatalogKindFilter] = useState<"all" | "bundled" | "optional">("all"); + const [catalogCategoryFilter, setCatalogCategoryFilter] = useState(""); + const [catalogSelectedPath, setCatalogSelectedPath] = useState("SKILL.md"); + const [expandedCatalogSkillId, setExpandedCatalogSkillId] = useState(null); + const [expandedCatalogDirs, setExpandedCatalogDirs] = useState>>({}); + const [installDialogState, setInstallDialogState] = useState<{ + open: boolean; + catalogSkill: CatalogSkill | null; + conflict: CompanySkillListItem | null; + defaultSlug: string | null; + defaultForce: boolean; + defaultAction: "install" | "update" | "replace"; + error: string | null; + }>({ open: false, catalogSkill: null, conflict: null, defaultSlug: null, defaultForce: false, defaultAction: "install", error: null }); + const [attachPopoverOpen, setAttachPopoverOpen] = useState(false); const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]); const routeSkillId = parsedRoute.skillId; const selectedPath = parsedRoute.filePath; + const viewParam = searchParams.get("view"); + const activeView: "installed" | "catalog" = viewParam === "catalog" ? "catalog" : "installed"; + const sourceFilterParam = searchParams.get("source") ?? "all"; + const sourceFilter: SourceFilter = (["all", "company", "bundled", "optional", "external"] as SourceFilter[]).includes(sourceFilterParam as SourceFilter) + ? (sourceFilterParam as SourceFilter) + : "all"; + const selectedCatalogRef = searchParams.get("catalog"); + + function setViewParam(view: "installed" | "catalog") { + setSearchParams((current) => { + const next = new URLSearchParams(current); + if (view === "installed") next.delete("view"); + else next.set("view", "catalog"); + return next; + }); + } + + function setSourceFilter(next: SourceFilter) { + setSearchParams((current) => { + const params = new URLSearchParams(current); + if (next === "all") params.delete("source"); + else params.set("source", next); + return params; + }); + } + + function selectCatalog(catalogRef: string | null, path = "SKILL.md") { + setSearchParams((current) => { + const params = new URLSearchParams(current); + if (catalogRef) params.set("catalog", catalogRef); + else params.delete("catalog"); + return params; + }); + setCatalogSelectedPath(path); + } useEffect(() => { setBreadcrumbs([ @@ -920,9 +1780,9 @@ export function CompanySkills() { }, [routeSkillId, skillsQuery.data]); useEffect(() => { - if (routeSkillId || !selectedSkillId) return; + if (activeView !== "installed" || routeSkillId || !selectedSkillId) return; navigate(skillRoute(selectedSkillId), { replace: true }); - }, [navigate, routeSkillId, selectedSkillId]); + }, [activeView, navigate, routeSkillId, selectedSkillId]); const detailQuery = useQuery({ queryKey: queryKeys.companySkills.detail(selectedCompanyId ?? "", selectedSkillId ?? ""), @@ -1009,8 +1869,7 @@ export function CompanySkills() { } const importSkill = useMutation({ - mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) => - companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken), + mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource), onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }); if (result.imported[0]) navigate(skillRoute(result.imported[0].id)); @@ -1023,7 +1882,6 @@ export function CompanySkills() { pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] }); } setSource(""); - setImportAuthToken(""); }, onError: (error) => { pushToast({ @@ -1149,6 +2007,190 @@ export function CompanySkills() { }, }); + const catalogListQuery = useQuery({ + queryKey: queryKeys.companySkills.catalog(), + queryFn: () => companySkillsApi.catalogList(), + enabled: Boolean(selectedCompanyId), + staleTime: 60_000, + }); + + const catalogDetailQuery = useQuery({ + queryKey: queryKeys.companySkills.catalogDetail(selectedCatalogRef ?? ""), + queryFn: () => companySkillsApi.catalogDetail(selectedCatalogRef!), + enabled: Boolean(selectedCompanyId && selectedCatalogRef && activeView === "catalog"), + staleTime: 60_000, + }); + + const catalogFileQuery = useQuery({ + queryKey: queryKeys.companySkills.catalogFile(selectedCatalogRef ?? "", catalogSelectedPath), + queryFn: () => companySkillsApi.catalogFile(selectedCatalogRef!, catalogSelectedPath), + enabled: Boolean(selectedCompanyId && selectedCatalogRef && activeView === "catalog" && catalogSelectedPath), + staleTime: 60_000, + }); + + const agentsQuery = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId ?? ""), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + const installedSkills = skillsQuery.data ?? []; + const installedByKey = useMemo( + () => new Map(installedSkills.map((skill) => [skill.key, skill])), + [installedSkills], + ); + const catalogCategories = useMemo(() => { + const set = new Set(); + for (const skill of catalogListQuery.data ?? []) set.add(skill.category); + return Array.from(set).sort(); + }, [catalogListQuery.data]); + + const selectedCatalogSkill = catalogDetailQuery.data + ?? (catalogListQuery.data ?? []).find((entry) => entry.id === selectedCatalogRef || entry.key === selectedCatalogRef) + ?? null; + + useEffect(() => { + setExpandedCatalogSkillId(selectedCatalogSkill?.id ?? null); + }, [selectedCatalogSkill?.id]); + + useEffect(() => { + if (!selectedCatalogSkill || catalogSelectedPath === "SKILL.md") return; + const parents = parentDirectoryPaths(catalogSelectedPath); + if (parents.length === 0) return; + setExpandedCatalogDirs((current) => { + const next = new Set(current[selectedCatalogSkill.id] ?? []); + let changed = false; + for (const parent of parents) { + if (!next.has(parent)) { + next.add(parent); + changed = true; + } + } + return changed ? { ...current, [selectedCatalogSkill.id]: next } : current; + }); + }, [catalogSelectedPath, selectedCatalogSkill]); + + const sourceCounts = useMemo>(() => { + const counts: Record = { all: installedSkills.length, company: 0, bundled: 0, optional: 0, external: 0 }; + for (const skill of installedSkills) { + const cls = classifySource(skill); + counts[cls] += 1; + } + return counts; + }, [installedSkills]); + + const installCatalog = useMutation({ + mutationFn: (payload: { catalogSkillId: string; slug: string | null; force: boolean }) => + companySkillsApi.installCatalog(selectedCompanyId!, { + catalogSkillId: payload.catalogSkillId, + slug: payload.slug, + force: payload.force, + }), + onSuccess: async (result) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }), + queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(selectedCompanyId!, result.skill.id) }), + ]); + setInstallDialogState((current) => ({ ...current, open: false, error: null })); + pushToast({ + tone: "success", + title: result.action === "created" ? "Skill installed" : result.action === "updated" ? "Skill updated" : "Skill is up to date", + body: result.skill.name, + }); + if (result.warnings[0]) { + pushToast({ tone: "warn", title: "Install warnings", body: result.warnings[0] }); + } + if (result.action === "created") { + setViewParam("installed"); + navigate(skillRoute(result.skill.id)); + } + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Failed to install catalog skill."; + setInstallDialogState((current) => ({ ...current, error: message })); + }, + }); + + const eligibleAgentsForAttach = useMemo(() => { + const data = agentsQuery.data ?? []; + return data.map((agent: Agent) => { + const caps = adapterCaps(agent.adapterType); + const requiredKeys: string[] = []; + const usedSet = new Set((activeDetail?.usedByAgents ?? []).map((entry) => entry.id)); + const isRequired = false; // detection currently lives server-side; default false until detail surfaces required state + return { + id: agent.id, + name: agent.name, + adapterType: agent.adapterType, + supportsSkills: Boolean(caps.supportsSkills), + required: isRequired, + attached: usedSet.has(agent.id), + requiredKeys, + }; + }); + }, [agentsQuery.data, adapterCaps, activeDetail]); + + const attachAgentsMutation = useMutation({ + mutationFn: async (input: { agentId: string; desiredSkills: string[] }) => { + return agentsApi.syncSkills(input.agentId, input.desiredSkills, selectedCompanyId ?? undefined); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }), + queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(selectedCompanyId!, selectedSkillId ?? "") }), + ]); + }, + }); + + async function handleAttachSubmit(nextAgentIds: string[]) { + if (!activeDetail) return; + const skillKey = activeDetail.key; + const targetSet = new Set(nextAgentIds); + const current = (activeDetail.usedByAgents ?? []).map((entry) => entry.id); + const currentSet = new Set(current); + const toAdd = nextAgentIds.filter((id) => !currentSet.has(id)); + const toRemove = current.filter((id) => !targetSet.has(id)); + const affected = new Set([...toAdd, ...toRemove]); + if (affected.size === 0) { + setAttachPopoverOpen(false); + return; + } + try { + for (const agentId of affected) { + const snapshot = await agentsApi.skills(agentId, selectedCompanyId ?? undefined); + const current = new Set(snapshot.desiredSkills ?? []); + if (targetSet.has(agentId)) current.add(skillKey); + else current.delete(skillKey); + await attachAgentsMutation.mutateAsync({ agentId, desiredSkills: Array.from(current) }); + } + pushToast({ tone: "success", title: "Agents updated", body: `${nextAgentIds.length} agent(s) attached.` }); + setAttachPopoverOpen(false); + } catch (error) { + pushToast({ tone: "error", title: "Update failed", body: error instanceof Error ? error.message : "Failed to update agent skills." }); + } + } + + function openInstallDialog(catalogSkill: CatalogSkill) { + const existing = installedByKey.get(catalogSkill.key) ?? null; + const installedHash = existing?.originHash ?? null; + const action: "install" | "update" | "replace" = existing + ? installedHash && installedHash !== catalogSkill.contentHash + ? "update" + : existing.sourceType !== "catalog" + ? "replace" + : "update" + : "install"; + setInstallDialogState({ + open: true, + catalogSkill, + conflict: existing, + defaultSlug: existing?.slug ?? catalogSkill.slug, + defaultForce: action === "replace", + defaultAction: action, + error: null, + }); + } + const deleteSkill = useMutation({ mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!), onSuccess: async (skill) => { @@ -1197,8 +2239,7 @@ export function CompanySkills() { setEmptySourceHelpOpen(true); return; } - const token = importAuthToken.trim() || undefined; - importSkill.mutate({ importSource: trimmedSource, authToken: token }); + importSkill.mutate(trimmedSource); } return ( @@ -1293,140 +2334,286 @@ export function CompanySkills() { -
- - -
- { - void updateStatusQuery.refetch(); - }} - checkUpdatesPending={updateStatusQuery.isFetching} - onInstallUpdate={() => installUpdate.mutate()} - installUpdatePending={installUpdate.isPending} - onDelete={openDeleteDialog} - deletePending={deleteSkill.isPending} - onSave={() => saveFile.mutate()} - savePending={saveFile.isPending} - />
+ + {activeView === "installed" ? ( +
+ + +
+ { + void updateStatusQuery.refetch(); + }} + checkUpdatesPending={updateStatusQuery.isFetching} + onInstallUpdate={() => installUpdate.mutate()} + installUpdatePending={installUpdate.isPending} + onDelete={openDeleteDialog} + deletePending={deleteSkill.isPending} + onSave={() => saveFile.mutate()} + savePending={saveFile.isPending} + attachAgents={eligibleAgentsForAttach} + attachPopoverOpen={attachPopoverOpen} + setAttachPopoverOpen={setAttachPopoverOpen} + onSubmitAttach={handleAttachSubmit} + attachPending={attachAgentsMutation.isPending} + /> +
+
+ ) : ( +
+ + +
+ selectedCatalogSkill && openInstallDialog(selectedCatalogSkill)} + onUpdate={() => selectedCatalogSkill && openInstallDialog(selectedCatalogSkill)} + onOpenInstalled={(skillId) => { + setViewParam("installed"); + navigate(skillRoute(skillId)); + }} + loadingPrimaryAction={installCatalog.isPending} + /> +
+
+ )}
); diff --git a/ui/storybook/stories/acpx-local.stories.tsx b/ui/storybook/stories/acpx-local.stories.tsx index 3bdec0e5..740b24cc 100644 --- a/ui/storybook/stories/acpx-local.stories.tsx +++ b/ui/storybook/stories/acpx-local.stories.tsx @@ -447,6 +447,10 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ sourceLabel: "Paperclip", sourceBadge: "paperclip", sourcePath: "skills/paperclip", + catalogKind: null, + originHash: null, + packageName: null, + packageVersion: null, }, { id: "skill-design-guide", @@ -470,6 +474,10 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ sourceLabel: "Local", sourceBadge: "local", sourcePath: "skills/design-guide", + catalogKind: null, + originHash: null, + packageName: null, + packageVersion: null, }, { id: "skill-mobile-qa", @@ -493,6 +501,10 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ sourceLabel: "Local", sourceBadge: "local", sourcePath: "skills/mobile-app-qa", + catalogKind: null, + originHash: null, + packageName: null, + packageVersion: null, }, ];