From 0c5f316f877e76d63cdbafd4a06dc6a948194425 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 29 May 2026 21:45:08 -0400 Subject: [PATCH] fix(ui): pull upstream skill UI to match new shared types Bumping shared CompanySkillListItem and CatalogSkill types in the previous commits without updating the UI consumers broke the storybook mocks (missing catalogKind, originHash, packageName, packageVersion) and the CompanySkills page (missing queryKeys.companySkills.catalog family). Replace the affected UI files with upstream/master: - ui/src/api/companySkills.ts - ui/src/pages/CompanySkills.tsx - ui/src/lib/queryKeys.ts - ui/storybook/stories/acpx-local.stories.tsx Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/api/companySkills.ts | 39 +- ui/src/lib/queryKeys.ts | 7 + ui/src/pages/CompanySkills.tsx | 1675 ++++++++++++++++--- ui/storybook/stories/acpx-local.stories.tsx | 12 + 4 files changed, 1482 insertions(+), 251 deletions(-) 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, }, ];