import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import type { CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityManifest, } from "@paperclipai/shared"; import { useNavigate, useLocation } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { companiesApi } from "../api/companies"; import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { MarkdownBody } from "../components/MarkdownBody"; import { cn } from "../lib/utils"; import { createZipArchive } from "../lib/zip"; import { Download, Package, Search, } from "lucide-react"; import { type FileTreeNode, type FrontmatterData, buildFileTree, countFiles, collectAllPaths, parseFrontmatter, FRONTMATTER_FIELD_LABELS, PackageFileTree, } from "../components/PackageFileTree"; /** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */ function isTaskPath(filePath: string): boolean { return /(?:^|\/)tasks\//.test(filePath); } /** * Extract the set of agent/project/task slugs that are "checked" based on * which file paths are in the checked set. * agents/{slug}/AGENT.md → agents slug * projects/{slug}/PROJECT.md → projects slug * tasks/{slug}/TASK.md → tasks slug */ function checkedSlugs(checkedFiles: Set): { agents: Set; projects: Set; tasks: Set; } { const agents = new Set(); const projects = new Set(); const tasks = new Set(); for (const p of checkedFiles) { const agentMatch = p.match(/^agents\/([^/]+)\//); if (agentMatch) agents.add(agentMatch[1]); const projectMatch = p.match(/^projects\/([^/]+)\//); if (projectMatch) projects.add(projectMatch[1]); const taskMatch = p.match(/^tasks\/([^/]+)\//); if (taskMatch) tasks.add(taskMatch[1]); } return { agents, projects, tasks }; } /** * Filter .paperclip.yaml content so it only includes entries whose * corresponding files are checked. Works by line-level YAML parsing * since the file has a known, simple structure produced by our own * renderYamlBlock. */ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { const slugs = checkedSlugs(checkedFiles); const lines = yaml.split("\n"); const out: string[] = []; // Sections whose entries are slug-keyed and should be filtered const filterableSections = new Set(["agents", "projects", "tasks"]); let currentSection: string | null = null; // top-level key (e.g. "agents") let currentEntry: string | null = null; // slug under that section let includeEntry = true; // Collect entries per section so we can omit empty section headers let sectionHeaderLine: string | null = null; let sectionBuffer: string[] = []; function flushSection() { if (sectionHeaderLine !== null && sectionBuffer.length > 0) { out.push(sectionHeaderLine); out.push(...sectionBuffer); } sectionHeaderLine = null; sectionBuffer = []; } for (const line of lines) { // Detect top-level key (no indentation) const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/); if (topMatch && !line.startsWith(" ")) { // Flush previous section flushSection(); currentEntry = null; includeEntry = true; const key = topMatch[0].split(":")[0]; if (filterableSections.has(key)) { currentSection = key; sectionHeaderLine = line; continue; } else { currentSection = null; out.push(line); continue; } } // Inside a filterable section if (currentSection && filterableSections.has(currentSection)) { // 2-space indented key = entry slug (slugs may start with digits/hyphens) const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/); if (entryMatch && !line.startsWith(" ")) { const slug = entryMatch[1]; currentEntry = slug; const sectionSlugs = slugs[currentSection as keyof typeof slugs]; includeEntry = sectionSlugs.has(slug); if (includeEntry) sectionBuffer.push(line); continue; } // Deeper indented line belongs to current entry if (currentEntry !== null) { if (includeEntry) sectionBuffer.push(line); continue; } // Shouldn't happen in well-formed output, but pass through sectionBuffer.push(line); continue; } // Outside filterable sections — pass through out.push(line); } // Flush last section flushSection(); return out.join("\n"); } /** Filter tree nodes whose path (or descendant paths) match a search string */ function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] { if (!query) return nodes; const lower = query.toLowerCase(); return nodes .map((node) => { if (node.kind === "file") { return node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower) ? node : null; } const filteredChildren = filterTree(node.children, query); return filteredChildren.length > 0 ? { ...node, children: filteredChildren } : null; }) .filter((n): n is FileTreeNode => n !== null); } /** Collect all ancestor dir paths for files that match a filter */ function collectMatchedParentDirs(nodes: FileTreeNode[], query: string): Set { const dirs = new Set(); const lower = query.toLowerCase(); function walk(node: FileTreeNode, ancestors: string[]) { if (node.kind === "file") { if (node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower)) { for (const a of ancestors) dirs.add(a); } } else { for (const child of node.children) { walk(child, [...ancestors, node.path]); } } } for (const node of nodes) walk(node, []); return dirs; } /** Sort tree: checked files first, then unchecked */ function sortByChecked(nodes: FileTreeNode[], checkedFiles: Set): FileTreeNode[] { return nodes.map((node) => { if (node.kind === "dir") { return { ...node, children: sortByChecked(node.children, checkedFiles) }; } return node; }).sort((a, b) => { if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1; if (a.kind === "file" && b.kind === "file") { const aChecked = checkedFiles.has(a.path); const bChecked = checkedFiles.has(b.path); if (aChecked !== bChecked) return aChecked ? -1 : 1; } return a.name.localeCompare(b.name); }); } const TASKS_PAGE_SIZE = 10; /** * Paginate children of `tasks/` directories: show up to `limit` entries, * but always include children that are checked or match the search query. * Returns the paginated tree and the total count of task children. */ function paginateTaskNodes( nodes: FileTreeNode[], limit: number, checkedFiles: Set, searchQuery: string, ): { nodes: FileTreeNode[]; totalTaskChildren: number; visibleTaskChildren: number } { let totalTaskChildren = 0; let visibleTaskChildren = 0; const result = nodes.map((node) => { // Only paginate direct children of "tasks" directories if (node.kind === "dir" && node.name === "tasks") { totalTaskChildren = node.children.length; // Partition children: pinned (checked or search-matched) vs rest const pinned: FileTreeNode[] = []; const rest: FileTreeNode[] = []; const lower = searchQuery.toLowerCase(); for (const child of node.children) { const childFiles = collectAllPaths([child], "file"); const isChecked = [...childFiles].some((p) => checkedFiles.has(p)); const isSearchMatch = searchQuery && ( child.name.toLowerCase().includes(lower) || child.path.toLowerCase().includes(lower) || [...childFiles].some((p) => p.toLowerCase().includes(lower)) ); if (isChecked || isSearchMatch) { pinned.push(child); } else { rest.push(child); } } // Show pinned + up to `limit` from rest const remaining = Math.max(0, limit - pinned.length); const visible = [...pinned, ...rest.slice(0, remaining)]; visibleTaskChildren = visible.length; return { ...node, children: visible }; } return node; }); return { nodes: result, totalTaskChildren, visibleTaskChildren }; } function downloadZip( exported: CompanyPortabilityExportResult, selectedFiles: Set, effectiveFiles: Record, ) { const filteredFiles: Record = {}; for (const [path] of Object.entries(exported.files)) { if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path]; } const zipBytes = createZipArchive(filteredFiles, exported.rootPath); const zipBuffer = new ArrayBuffer(zipBytes.byteLength); new Uint8Array(zipBuffer).set(zipBytes); const blob = new Blob([zipBuffer], { type: "application/zip" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${exported.rootPath}.zip`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); } // ── Frontmatter card (export-specific: skill click support) ────────── function FrontmatterCard({ data, onSkillClick, }: { data: FrontmatterData; onSkillClick?: (skill: string) => void; }) { return (
{Object.entries(data).map(([key, value]) => (
{FRONTMATTER_FIELD_LABELS[key] ?? key}
{Array.isArray(value) ? (
{value.map((item) => ( ))}
) : ( {value} )}
))}
); } // ── Client-side README generation ──────────────────────────────────── const ROLE_LABELS: Record = { ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", coo: "COO", vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent", }; /** Sanitize slug for use as a Mermaid node ID. */ function mermaidId(slug: string): string { return slug.replace(/[^a-zA-Z0-9_]/g, "_"); } /** Escape text for Mermaid node labels. */ function mermaidEscape(s: string): string { return s.replace(/"/g, """).replace(//g, ">"); } /** Generate a Mermaid org chart from the selected agents. */ function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null { if (agents.length === 0) return null; const lines: string[] = []; lines.push("```mermaid"); lines.push("graph TD"); for (const agent of agents) { const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; lines.push(` ${mermaidId(agent.slug)}["${mermaidEscape(agent.name)}
${mermaidEscape(roleLabel)}"]`); } const slugSet = new Set(agents.map((a) => a.slug)); for (const agent of agents) { if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) { lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`); } } lines.push("```"); return lines.join("\n"); } /** * Regenerate README.md content based on the currently checked files. * Only counts/lists entities whose files are in the checked set. */ function generateReadmeFromSelection( manifest: CompanyPortabilityManifest, checkedFiles: Set, companyName: string, companyDescription: string | null, ): string { const slugs = checkedSlugs(checkedFiles); const agents = manifest.agents.filter((a) => slugs.agents.has(a.slug)); const projects = manifest.projects.filter((p) => slugs.projects.has(p.slug)); const tasks = manifest.issues.filter((t) => slugs.tasks.has(t.slug)); const skills = manifest.skills.filter((s) => { // Skill files live under skills/{key}/... return [...checkedFiles].some((f) => f.startsWith(`skills/${s.key}/`) || f.startsWith(`skills/`) && f.includes(`/${s.slug}/`)); }); const lines: string[] = []; lines.push(`# ${companyName}`); lines.push(""); if (companyDescription) { lines.push(`> ${companyDescription}`); lines.push(""); } // Org chart as Mermaid diagram const mermaid = generateOrgChartMermaid(agents); if (mermaid) { lines.push(mermaid); lines.push(""); } lines.push("## What's Inside"); lines.push(""); lines.push("This is an [Agent Company](https://paperclip.ing) package."); lines.push(""); const counts: Array<[string, number]> = []; if (agents.length > 0) counts.push(["Agents", agents.length]); if (projects.length > 0) counts.push(["Projects", projects.length]); if (skills.length > 0) counts.push(["Skills", skills.length]); if (tasks.length > 0) counts.push(["Tasks", tasks.length]); if (counts.length > 0) { lines.push("| Content | Count |"); lines.push("|---------|-------|"); for (const [label, count] of counts) { lines.push(`| ${label} | ${count} |`); } lines.push(""); } if (agents.length > 0) { lines.push("### Agents"); lines.push(""); lines.push("| Agent | Role | Reports To |"); lines.push("|-------|------|------------|"); for (const agent of agents) { const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; const reportsTo = agent.reportsToSlug ?? "\u2014"; lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`); } lines.push(""); } if (projects.length > 0) { lines.push("### Projects"); lines.push(""); for (const project of projects) { const desc = project.description ? ` \u2014 ${project.description}` : ""; lines.push(`- **${project.name}**${desc}`); } lines.push(""); } lines.push("## Getting Started"); lines.push(""); lines.push("```bash"); lines.push("pnpm paperclipai company import this-github-url-or-folder"); lines.push("```"); lines.push(""); lines.push("See [Paperclip](https://paperclip.ing) for more information."); lines.push(""); lines.push("---"); lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`); lines.push(""); return lines.join("\n"); } // ── Preview pane ────────────────────────────────────────────────────── function ExportPreviewPane({ selectedFile, content, onSkillClick, }: { selectedFile: string | null; content: string | null; onSkillClick?: (skill: string) => void; }) { if (!selectedFile || content === null) { return ( ); } const isMarkdown = selectedFile.endsWith(".md"); const parsed = isMarkdown ? parseFrontmatter(content) : null; return (
{selectedFile}
{parsed ? ( <> {parsed.body.trim() && {parsed.body}} ) : isMarkdown ? ( {content} ) : (
            {content}
          
)}
); } // ── Main page ───────────────────────────────────────────────────────── /** Extract the file path from the current URL pathname (after /company/export/files/) */ function filePathFromLocation(pathname: string): string | null { const marker = "/company/export/files/"; const idx = pathname.indexOf(marker); if (idx === -1) return null; const filePath = decodeURIComponent(pathname.slice(idx + marker.length)); return filePath || null; } /** Expand all ancestor directories for a given file path */ function expandAncestors(filePath: string): string[] { const parts = filePath.split("/").slice(0, -1); const dirs: string[] = []; let current = ""; for (const part of parts) { current = current ? `${current}/${part}` : part; dirs.push(current); } return dirs; } export function CompanyExport() { const { selectedCompanyId, selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const navigate = useNavigate(); const location = useLocation(); const [exportData, setExportData] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [checkedFiles, setCheckedFiles] = useState>(new Set()); const [treeSearch, setTreeSearch] = useState(""); const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE); const savedExpandedRef = useRef | null>(null); const initialFileFromUrl = useRef(filePathFromLocation(location.pathname)); // Navigate-aware file selection: updates state + URL without page reload. // `replace` = true skips history entry (used for initial load); false = pushes (used for clicks). const selectFile = useCallback( (filePath: string | null, replace = false) => { setSelectedFile(filePath); if (filePath) { navigate(`/company/export/files/${encodeURI(filePath)}`, { replace }); } else { navigate("/company/export", { replace }); } }, [navigate], ); // Sync selectedFile from URL on browser back/forward useEffect(() => { if (!exportData) return; const urlFile = filePathFromLocation(location.pathname); if (urlFile && urlFile in exportData.files && urlFile !== selectedFile) { setSelectedFile(urlFile); // Expand ancestors so the file is visible in the tree setExpandedDirs((prev) => { const next = new Set(prev); for (const dir of expandAncestors(urlFile)) next.add(dir); return next; }); } else if (!urlFile && selectedFile) { setSelectedFile(null); } }, [location.pathname, exportData]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { setBreadcrumbs([ { label: "Org Chart", href: "/org" }, { label: "Export" }, ]); }, [setBreadcrumbs]); const exportPreviewMutation = useMutation({ mutationFn: () => companiesApi.exportPreview(selectedCompanyId!, { include: { company: true, agents: true, projects: true, issues: true }, }), onSuccess: (result) => { setExportData(result); setCheckedFiles((prev) => { const next = new Set(); for (const filePath of Object.keys(result.files)) { if (prev.has(filePath)) next.add(filePath); else if (!isTaskPath(filePath)) next.add(filePath); } return next; }); // Expand top-level dirs (except tasks — collapsed by default) const tree = buildFileTree(result.files); const topDirs = new Set(); for (const node of tree) { if (node.kind === "dir" && node.name !== "tasks") topDirs.add(node.path); } // If URL contains a deep-linked file path, select it and expand ancestors const urlFile = initialFileFromUrl.current; if (urlFile && urlFile in result.files) { setSelectedFile(urlFile); const ancestors = expandAncestors(urlFile); setExpandedDirs(new Set([...topDirs, ...ancestors])); } else { // Select first file and update URL const firstFile = Object.keys(result.files)[0]; if (firstFile) { selectFile(firstFile, true); } setExpandedDirs(topDirs); } }, onError: (err) => { pushToast({ tone: "error", title: "Export failed", body: err instanceof Error ? err.message : "Failed to load export data.", }); }, }); const downloadMutation = useMutation({ mutationFn: () => companiesApi.exportPackage(selectedCompanyId!, { include: { company: true, agents: true, projects: true, issues: true }, selectedFiles: Array.from(checkedFiles).sort(), }), onSuccess: (result) => { const resultCheckedFiles = new Set(Object.keys(result.files)); downloadZip(result, resultCheckedFiles, result.files); pushToast({ tone: "success", title: "Export downloaded", body: `${resultCheckedFiles.size} file${resultCheckedFiles.size === 1 ? "" : "s"} exported as ${result.rootPath}.zip`, }); }, onError: (err) => { pushToast({ tone: "error", title: "Export failed", body: err instanceof Error ? err.message : "Failed to build export package.", }); }, }); useEffect(() => { if (!selectedCompanyId || exportPreviewMutation.isPending) return; setExportData(null); exportPreviewMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCompanyId]); const tree = useMemo( () => (exportData ? buildFileTree(exportData.files) : []), [exportData], ); const { displayTree, totalTaskChildren, visibleTaskChildren } = useMemo(() => { let result = tree; if (treeSearch) result = filterTree(result, treeSearch); result = sortByChecked(result, checkedFiles); const paginated = paginateTaskNodes(result, taskLimit, checkedFiles, treeSearch); return { displayTree: paginated.nodes, totalTaskChildren: paginated.totalTaskChildren, visibleTaskChildren: paginated.visibleTaskChildren, }; }, [tree, treeSearch, checkedFiles, taskLimit]); // Recompute .paperclip.yaml and README.md content whenever checked files // change so the preview & download always reflect the current selection. const effectiveFiles = useMemo(() => { if (!exportData) return {} as Record; const filtered = { ...exportData.files }; // Filter .paperclip.yaml const yamlPath = exportData.paperclipExtensionPath; if (yamlPath && exportData.files[yamlPath]) { filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles); } // Regenerate README.md based on checked selection if (exportData.files["README.md"]) { const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company"; const companyDescription = exportData.manifest.company?.description ?? null; filtered["README.md"] = generateReadmeFromSelection( exportData.manifest, checkedFiles, companyName, companyDescription, ); } return filtered; }, [exportData, checkedFiles, selectedCompany?.name]); const totalFiles = useMemo(() => countFiles(tree), [tree]); const selectedCount = checkedFiles.size; // Filter out terminated agent messages — they don't need to be shown const warnings = useMemo(() => { if (!exportData) return [] as string[]; return exportData.warnings.filter((w) => !/terminated agent/i.test(w)); }, [exportData]); function handleToggleDir(path: string) { setExpandedDirs((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); } function handleToggleCheck(path: string, kind: "file" | "dir") { if (!exportData) return; setCheckedFiles((prev) => { const next = new Set(prev); if (kind === "file") { if (next.has(path)) next.delete(path); else next.add(path); } else { // Find all child file paths under this dir const dirTree = buildFileTree(exportData.files); const findNode = (nodes: FileTreeNode[], target: string): FileTreeNode | null => { for (const n of nodes) { if (n.path === target) return n; const found = findNode(n.children, target); if (found) return found; } return null; }; const dirNode = findNode(dirTree, path); if (dirNode) { const childFiles = collectAllPaths(dirNode.children, "file"); // Add the dir's own file children for (const child of dirNode.children) { if (child.kind === "file") childFiles.add(child.path); } const allChecked = [...childFiles].every((p) => next.has(p)); for (const f of childFiles) { if (allChecked) next.delete(f); else next.add(f); } } } return next; }); } function handleSearchChange(query: string) { const wasSearching = treeSearch.length > 0; const isSearching = query.length > 0; if (isSearching && !wasSearching) { // Save current expansion state before search savedExpandedRef.current = new Set(expandedDirs); } setTreeSearch(query); if (isSearching) { // Expand all parent dirs of matched files const matchedParents = collectMatchedParentDirs(tree, query); setExpandedDirs((prev) => { const next = new Set(prev); for (const d of matchedParents) next.add(d); return next; }); } else if (wasSearching) { // Restore pre-search expansion state if (savedExpandedRef.current) { setExpandedDirs(savedExpandedRef.current); savedExpandedRef.current = null; } } } function handleSkillClick(skillKey: string) { if (!exportData) return; const manifestSkill = exportData.manifest.skills.find( (skill) => skill.key === skillKey || skill.slug === skillKey, ); const skillPath = manifestSkill?.path ?? `skills/${skillKey}/SKILL.md`; if (!(skillPath in exportData.files)) return; selectFile(skillPath); setExpandedDirs((prev) => { const next = new Set(prev); next.add("skills"); const parts = skillPath.split("/").slice(0, -1); let current = ""; for (const part of parts) { current = current ? `${current}/${part}` : part; next.add(current); } return next; }); } function handleDownload() { if (!exportData || checkedFiles.size === 0 || downloadMutation.isPending) return; downloadMutation.mutate(); } if (!selectedCompanyId) { return ; } if (exportPreviewMutation.isPending && !exportData) { return ; } if (!exportData) { return ; } const previewContent = selectedFile ? (effectiveFiles[selectedFile] ?? null) : null; return (
{/* Sticky top action bar */}
{selectedCompany?.name ?? "Company"} export {selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected {warnings.length > 0 && ( {warnings.length} warning{warnings.length === 1 ? "" : "s"} )}
{/* Warnings */} {warnings.length > 0 && (
{warnings.map((w) => (
{w}
))}
)} {/* Two-column layout */}
); }