import { useEffect, useMemo, useState, type SVGProps } from "react"; import { VOCAB } from "@paperclipai/branding"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, CompanySkillFileInventoryEntry, CompanySkillListItem, CompanySkillProjectScanResult, CompanySkillSourceBadge, CompanySkillUpdateStatus, } from "@paperclipai/shared"; import { companySkillsApi } from "../api/companySkills"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { MarkdownBody } from "../components/MarkdownBody"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { PageSkeleton } from "../components/PageSkeleton"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Boxes, ChevronDown, ChevronRight, Code2, Eye, FileCode2, FileText, Folder, FolderOpen, Github, Link2, ExternalLink, Paperclip, Pencil, Plus, RefreshCw, Save, Search, } from "lucide-react"; type SkillTreeNode = { name: string; path: string | null; kind: "dir" | "file"; fileKind?: CompanySkillFileInventoryEntry["kind"]; children: SkillTreeNode[]; }; const SKILL_TREE_BASE_INDENT = 16; const SKILL_TREE_STEP_INDENT = 24; const SKILL_TREE_ROW_HEIGHT_CLASS = "min-h-9"; function VercelMark(props: SVGProps) { return ( ); } function stripFrontmatter(markdown: string) { const normalized = markdown.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) return normalized.trim(); const closing = normalized.indexOf("\n---\n", 4); if (closing < 0) return normalized.trim(); return normalized.slice(closing + 5).trim(); } function splitFrontmatter(markdown: string): { frontmatter: string | null; body: string } { const normalized = markdown.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) { return { frontmatter: null, body: normalized }; } const closing = normalized.indexOf("\n---\n", 4); if (closing < 0) { return { frontmatter: null, body: normalized }; } return { frontmatter: normalized.slice(4, closing).trim(), body: normalized.slice(closing + 5).trimStart(), }; } function mergeFrontmatter(markdown: string, body: string) { const parsed = splitFrontmatter(markdown); if (!parsed.frontmatter) return body; return ["---", parsed.frontmatter, "---", "", body].join("\n"); } function buildTree(entries: CompanySkillFileInventoryEntry[]) { const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] }; for (const entry of entries) { const segments = entry.path.split("/").filter(Boolean); let current = root; let currentPath = ""; for (const [index, segment] of segments.entries()) { currentPath = currentPath ? `${currentPath}/${segment}` : segment; const isLeaf = index === segments.length - 1; let next = current.children.find((child) => child.name === segment); if (!next) { next = { name: segment, path: isLeaf ? entry.path : currentPath, kind: isLeaf ? "file" : "dir", fileKind: isLeaf ? entry.kind : undefined, children: [], }; current.children.push(next); } current = next; } } function sortNode(node: SkillTreeNode) { node.children.sort((left, right) => { if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1; if (left.name === "SKILL.md") return -1; if (right.name === "SKILL.md") return 1; return left.name.localeCompare(right.name); }); node.children.forEach(sortNode); } sortNode(root); return root.children; } function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string | null) { const normalizedLabel = sourceLabel?.toLowerCase() ?? ""; const isSkillsShManaged = normalizedLabel.includes("skills.sh") || normalizedLabel.includes("vercel-labs/skills"); switch (sourceBadge) { case "skills_sh": return { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" }; case "github": return isSkillsShManaged ? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" } : { icon: Github, label: sourceLabel ?? "GitHub", managedLabel: "GitHub managed" }; case "url": return { icon: Link2, label: sourceLabel ?? "URL", managedLabel: "URL managed" }; case "local": return { icon: Folder, label: sourceLabel ?? "Folder", managedLabel: "Folder managed" }; case "paperclip": return { icon: Paperclip, label: sourceLabel ?? VOCAB.appName, managedLabel: `${VOCAB.appName} managed` }; default: return { icon: Boxes, label: sourceLabel ?? "Catalog", managedLabel: "Catalog managed" }; } } function shortRef(ref: string | null | undefined) { if (!ref) return null; return ref.slice(0, 7); } function formatProjectScanSummary(result: CompanySkillProjectScanResult) { const parts = [ `${result.discovered} found`, `${result.imported.length} imported`, `${result.updated.length} updated`, ]; if (result.conflicts.length > 0) parts.push(`${result.conflicts.length} conflicts`); if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`); return `${parts.join(", ")} across ${result.scannedWorkspaces} workspace${result.scannedWorkspaces === 1 ? "" : "s"}.`; } function fileIcon(kind: CompanySkillFileInventoryEntry["kind"]) { if (kind === "script" || kind === "reference") return FileCode2; return FileText; } function encodeSkillFilePath(filePath: string) { return filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/"); } function decodeSkillFilePath(filePath: string | undefined) { if (!filePath) return "SKILL.md"; return filePath .split("/") .filter(Boolean) .map((segment) => { try { return decodeURIComponent(segment); } catch { return segment; } }) .join("/"); } function parseSkillRoute(routePath: string | undefined) { const segments = (routePath ?? "").split("/").filter(Boolean); if (segments.length === 0) { return { skillId: null, filePath: "SKILL.md" }; } const [rawSkillId, rawMode, ...rest] = segments; const skillId = rawSkillId ? decodeURIComponent(rawSkillId) : null; if (!skillId) { return { skillId: null, filePath: "SKILL.md" }; } if (rawMode === "files") { return { skillId, filePath: decodeSkillFilePath(rest.join("/")), }; } return { skillId, filePath: "SKILL.md" }; } function skillRoute(skillId: string, filePath?: string | null) { return filePath ? `/skills/${skillId}/files/${encodeSkillFilePath(filePath)}` : `/skills/${skillId}`; } function parentDirectoryPaths(filePath: string) { const segments = filePath.split("/").filter(Boolean); const parents: string[] = []; for (let index = 0; index < segments.length - 1; index += 1) { parents.push(segments.slice(0, index + 1).join("/")); } return parents; } function NewSkillForm({ onCreate, isPending, onCancel, }: { onCreate: (payload: CompanySkillCreateRequest) => void; isPending: boolean; onCancel: () => void; }) { const [name, setName] = useState(""); const [slug, setSlug] = useState(""); const [description, setDescription] = useState(""); return (
setName(event.target.value)} placeholder="Skill name" className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0" /> setSlug(event.target.value)} placeholder="optional-shortname" className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0" />