import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { CompanyPortabilityPreviewResult, CompanyPortabilitySource, } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { companiesApi } from "../api/companies"; import { queryKeys } from "../lib/queryKeys"; import { MarkdownBody } from "../components/MarkdownBody"; import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { cn } from "../lib/utils"; import { ArrowRight, Check, Download, Github, Package, Upload, } from "lucide-react"; import { Field } from "../components/agent-config-primitives"; import { type FileTreeNode, type FrontmatterData, buildFileTree, countFiles, collectAllPaths, parseFrontmatter, FRONTMATTER_FIELD_LABELS, PackageFileTree, } from "../components/PackageFileTree"; import { readZipArchive } from "../lib/zip"; // ── Import-specific helpers ─────────────────────────────────────────── /** Build a map from file path → planned action (create/update/skip) using the manifest + plan */ function buildActionMap(preview: CompanyPortabilityPreviewResult): Map { const map = new Map(); const manifest = preview.manifest; for (const ap of preview.plan.agentPlans) { const agent = manifest.agents.find((a) => a.slug === ap.slug); if (agent) { const path = ensureMarkdownPath(agent.path); map.set(path, ap.action); } } for (const pp of preview.plan.projectPlans) { const project = manifest.projects.find((p) => p.slug === pp.slug); if (project) { const path = ensureMarkdownPath(project.path); map.set(path, pp.action); } } for (const ip of preview.plan.issuePlans) { const issue = manifest.issues.find((i) => i.slug === ip.slug); if (issue) { const path = ensureMarkdownPath(issue.path); map.set(path, ip.action); } } for (const skill of manifest.skills) { const path = ensureMarkdownPath(skill.path); map.set(path, "create"); // Also mark skill file inventory for (const file of skill.fileInventory) { if (preview.files[file.path]) { map.set(file.path, "create"); } } } // Company file if (manifest.company) { const path = ensureMarkdownPath(manifest.company.path); map.set(path, preview.plan.companyAction === "none" ? "skip" : preview.plan.companyAction); } return map; } function ensureMarkdownPath(p: string): string { return p.endsWith(".md") ? p : `${p}.md`; } const ACTION_COLORS: Record = { create: "text-emerald-500 border-emerald-500/30", update: "text-amber-500 border-amber-500/30", overwrite: "text-red-500 border-red-500/30", replace: "text-red-500 border-red-500/30", skip: "text-muted-foreground border-border", none: "text-muted-foreground border-border", }; function FrontmatterCard({ data }: { data: FrontmatterData }) { return (
{Object.entries(data).map(([key, value]) => (
{FRONTMATTER_FIELD_LABELS[key] ?? key}
{Array.isArray(value) ? (
{value.map((item) => ( {item} ))}
) : ( {value} )}
))}
); } // ── Import file tree customization ─────────────────────────────────── function renderImportFileExtra(node: FileTreeNode, checked: boolean, renameMap: Map) { // Show rename indicator only on directories (folders), not individual files const renamedTo = node.kind === "dir" ? renameMap.get(node.path) : undefined; const actionBadge = node.action ? ( {checked ? node.action : "skip"} ) : null; if (!actionBadge && !renamedTo) return null; return ( {renamedTo && checked && ( → {renamedTo} )} {actionBadge} ); } function importFileRowClassName(_node: FileTreeNode, checked: boolean) { return !checked ? "opacity-50" : undefined; } // ── Preview pane ────────────────────────────────────────────────────── function ImportPreviewPane({ selectedFile, content, action, renamedTo, }: { selectedFile: string | null; content: string | null; action: string | null; renamedTo: string | null; }) { if (!selectedFile || content === null) { return ( ); } const isMarkdown = selectedFile.endsWith(".md"); const parsed = isMarkdown ? parseFrontmatter(content) : null; const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : ""; return (
{selectedFile} {renamedTo && ( → {renamedTo} )}
{action && ( {action} )}
{parsed ? ( <> {parsed.body.trim() && {parsed.body}} ) : isMarkdown ? ( {content} ) : (
            {content}
          
)}
); } // ── Conflict item type ─────────────────────────────────────────────── interface ConflictItem { slug: string; kind: "agent" | "project" | "issue" | "skill"; originalName: string; plannedName: string; filePath: string | null; action: "rename" | "update"; } function buildConflictList( preview: CompanyPortabilityPreviewResult, ): ConflictItem[] { const conflicts: ConflictItem[] = []; const manifest = preview.manifest; // Agents with collisions for (const ap of preview.plan.agentPlans) { if (ap.existingAgentId) { const agent = manifest.agents.find((a) => a.slug === ap.slug); conflicts.push({ slug: ap.slug, kind: "agent", originalName: agent?.name ?? ap.slug, plannedName: ap.plannedName, filePath: agent ? ensureMarkdownPath(agent.path) : null, action: ap.action === "update" ? "update" : "rename", }); } } // Projects with collisions for (const pp of preview.plan.projectPlans) { if (pp.existingProjectId) { const project = manifest.projects.find((p) => p.slug === pp.slug); conflicts.push({ slug: pp.slug, kind: "project", originalName: project?.name ?? pp.slug, plannedName: pp.plannedName, filePath: project ? ensureMarkdownPath(project.path) : null, action: pp.action === "update" ? "update" : "rename", }); } } return conflicts; } /** Extract a prefix from the import source URL or uploaded zip package name */ function deriveSourcePrefix( sourceMode: string, importUrl: string, localPackageName: string | null, localRootPath: string | null, ): string | null { if (sourceMode === "local") { if (localRootPath) return localRootPath.split("/").pop() ?? null; if (!localPackageName) return null; return localPackageName.replace(/\.zip$/i, "") || null; } if (sourceMode === "github") { const url = importUrl.trim(); if (!url) return null; try { const pathname = new URL(url.startsWith("http") ? url : `https://${url}`).pathname; // For github URLs like /owner/repo/tree/branch/path - take last segment const segments = pathname.split("/").filter(Boolean); return segments.length > 0 ? segments[segments.length - 1] : null; } catch { return null; } } return null; } /** Generate a prefix-based rename: e.g. "gstack" + "CEO" → "gstack-CEO" */ function prefixedName(prefix: string | null, originalName: string): string { if (!prefix) return originalName; return `${prefix}-${originalName}`; } // ── Conflict resolution UI ─────────────────────────────────────────── function ConflictResolutionList({ conflicts, nameOverrides, skippedSlugs, confirmedSlugs, onRename, onToggleSkip, onToggleConfirm, }: { conflicts: ConflictItem[]; nameOverrides: Record; skippedSlugs: Set; confirmedSlugs: Set; onRename: (slug: string, newName: string) => void; onToggleSkip: (slug: string, filePath: string | null) => void; onToggleConfirm: (slug: string) => void; }) { if (conflicts.length === 0) return null; return (

Renames

{conflicts.length} item{conflicts.length === 1 ? "" : "s"}
{conflicts.map((item) => { const isSkipped = skippedSlugs.has(item.slug); const isConfirmed = confirmedSlugs.has(item.slug); const currentName = nameOverrides[item.slug] ?? item.plannedName; return (
{/* Skip button on the left */} {item.kind} {item.originalName} {!isSkipped && ( <> {isConfirmed ? ( {currentName} ) : ( onRename(item.slug, e.target.value)} /> )} )} {/* Confirm rename button on the right */} {!isSkipped && ( )}
); })}
); } // ── Helpers ─────────────────────────────────────────────────────────── async function readLocalPackageZip(file: File): Promise<{ name: string; rootPath: string | null; files: Record; }> { if (!/\.zip$/i.test(file.name)) { throw new Error("Select a .zip company package."); } const archive = readZipArchive(await file.arrayBuffer()); if (Object.keys(archive.files).length === 0) { throw new Error("No package files were found in the selected zip archive."); } return { name: file.name, rootPath: archive.rootPath, files: archive.files, }; } // ── Main page ───────────────────────────────────────────────────────── export function CompanyImport() { const { selectedCompanyId, selectedCompany, setSelectedCompanyId, } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); const packageInputRef = useRef(null); // Source state const [sourceMode, setSourceMode] = useState<"github" | "local">("github"); const [importUrl, setImportUrl] = useState(""); const [localPackage, setLocalPackage] = useState<{ name: string; rootPath: string | null; files: Record; } | null>(null); // Target state const [targetMode, setTargetMode] = useState<"existing" | "new">("new"); const [newCompanyName, setNewCompanyName] = useState(""); // Preview state const [importPreview, setImportPreview] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [checkedFiles, setCheckedFiles] = useState>(new Set()); // Conflict resolution state const [nameOverrides, setNameOverrides] = useState>({}); const [skippedSlugs, setSkippedSlugs] = useState>(new Set()); const [confirmedSlugs, setConfirmedSlugs] = useState>(new Set()); useEffect(() => { setBreadcrumbs([ { label: "Org Chart", href: "/org" }, { label: "Import" }, ]); }, [setBreadcrumbs]); function buildSource(): CompanyPortabilitySource | null { if (sourceMode === "local") { if (!localPackage) return null; return { type: "inline", rootPath: localPackage.rootPath, files: localPackage.files }; } const url = importUrl.trim(); if (!url) return null; return { type: "github", url }; } // Preview mutation const previewMutation = useMutation({ mutationFn: () => { const source = buildSource(); if (!source) throw new Error("No source configured."); return companiesApi.importPreview({ source, include: { company: true, agents: true, projects: true, issues: true }, target: targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, collisionStrategy: "rename", }); }, onSuccess: (result) => { setImportPreview(result); // Build conflicts and set default name overrides with prefix const conflicts = buildConflictList(result); const prefix = deriveSourcePrefix( sourceMode, importUrl, localPackage?.name ?? null, localPackage?.rootPath ?? null, ); const defaultOverrides: Record = {}; for (const c of conflicts) { if (c.action === "rename" && prefix) { // Use prefix-based default rename defaultOverrides[c.slug] = prefixedName(prefix, c.originalName); } } setNameOverrides(defaultOverrides); setSkippedSlugs(new Set()); setConfirmedSlugs(new Set()); // Check all files by default, then uncheck COMPANY.md for existing company const allFiles = new Set(Object.keys(result.files)); if (targetMode === "existing" && result.manifest.company && result.plan.companyAction === "update") { const companyPath = ensureMarkdownPath(result.manifest.company.path); allFiles.delete(companyPath); } setCheckedFiles(allFiles); // Expand top-level dirs + all ancestor dirs of files with conflicts (update action) const am = buildActionMap(result); const tree = buildFileTree(result.files, am); const dirsToExpand = new Set(); for (const node of tree) { if (node.kind === "dir") dirsToExpand.add(node.path); } // Auto-expand directories containing conflicting files so they're visible for (const [filePath, action] of am) { if (action === "update") { const segments = filePath.split("/").filter(Boolean); let current = ""; for (let i = 0; i < segments.length - 1; i++) { current = current ? `${current}/${segments[i]}` : segments[i]; dirsToExpand.add(current); } } } setExpandedDirs(dirsToExpand); // Select first file const firstFile = Object.keys(result.files)[0]; if (firstFile) setSelectedFile(firstFile); }, onError: (err) => { pushToast({ tone: "error", title: "Preview failed", body: err instanceof Error ? err.message : "Failed to preview import.", }); }, }); // Build the final nameOverrides to send (only overrides that differ from plannedName) function buildFinalNameOverrides(): Record | undefined { if (!importPreview) return undefined; const overrides: Record = {}; for (const [slug, name] of Object.entries(nameOverrides)) { if (name.trim()) { overrides[slug] = name.trim(); } } return Object.keys(overrides).length > 0 ? overrides : undefined; } // Apply mutation const importMutation = useMutation({ mutationFn: () => { const source = buildSource(); if (!source) throw new Error("No source configured."); return companiesApi.importBundle({ source, include: { company: true, agents: true, projects: true, issues: true }, target: targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, collisionStrategy: "rename", nameOverrides: buildFinalNameOverrides(), }); }, onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); if (result.company.action === "created") { setSelectedCompanyId(result.company.id); } pushToast({ tone: "success", title: "Import complete", body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`, }); // Reset setImportPreview(null); setLocalPackage(null); setImportUrl(""); setNameOverrides({}); setSkippedSlugs(new Set()); setConfirmedSlugs(new Set()); }, onError: (err) => { pushToast({ tone: "error", title: "Import failed", body: err instanceof Error ? err.message : "Failed to apply import.", }); }, }); async function handleChooseLocalPackage(e: ChangeEvent) { const fileList = e.target.files; if (!fileList || fileList.length === 0) return; try { const pkg = await readLocalPackageZip(fileList[0]!); setLocalPackage(pkg); setImportPreview(null); } catch (err) { pushToast({ tone: "error", title: "Package read failed", body: err instanceof Error ? err.message : "Failed to read folder.", }); } } const actionMap = useMemo( () => (importPreview ? buildActionMap(importPreview) : new Map()), [importPreview], ); const tree = useMemo( () => (importPreview ? buildFileTree(importPreview.files, actionMap) : []), [importPreview, actionMap], ); const conflicts = useMemo( () => (importPreview ? buildConflictList(importPreview) : []), [importPreview], ); // Map directory paths → planned rename name for display in the file tree // Also maps file paths for use in the preview header const renameMap = useMemo(() => { const map = new Map(); if (!importPreview) return map; for (const c of conflicts) { if (!c.filePath) continue; const isSkipped = skippedSlugs.has(c.slug); if (isSkipped) continue; const renamedTo = nameOverrides[c.slug] ?? c.plannedName; if (renamedTo === c.originalName) continue; // Map the parent directory (e.g. agents/ceo → gstack-ceo) for the file tree const parentDir = c.filePath.split("/").slice(0, -1).join("/"); if (parentDir) map.set(parentDir, renamedTo); // Map the file path too — used by the preview header, not shown in tree map.set(c.filePath, renamedTo); } return map; }, [importPreview, conflicts, nameOverrides, skippedSlugs]); const totalFiles = useMemo(() => countFiles(tree), [tree]); const selectedCount = checkedFiles.size; 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 (!importPreview) return; setCheckedFiles((prev) => { const next = new Set(prev); if (kind === "file") { if (next.has(path)) next.delete(path); else next.add(path); } else { 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(tree, path); if (dirNode) { const childFiles = collectAllPaths(dirNode.children, "file"); 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 handleConflictRename(slug: string, newName: string) { setNameOverrides((prev) => ({ ...prev, [slug]: newName })); // Editing the name un-confirms setConfirmedSlugs((prev) => { if (!prev.has(slug)) return prev; const next = new Set(prev); next.delete(slug); return next; }); } function handleConflictToggleConfirm(slug: string) { setConfirmedSlugs((prev) => { const next = new Set(prev); if (next.has(slug)) next.delete(slug); else next.add(slug); return next; }); } function handleConflictToggleSkip(slug: string, filePath: string | null) { setSkippedSlugs((prev) => { const next = new Set(prev); const wasSkipped = next.has(slug); if (wasSkipped) { next.delete(slug); } else { next.add(slug); } // Sync with file tree checkboxes if (filePath) { setCheckedFiles((prevChecked) => { const nextChecked = new Set(prevChecked); if (wasSkipped) { nextChecked.add(filePath); } else { nextChecked.delete(filePath); } return nextChecked; }); } return next; }); } const hasSource = sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0; const hasErrors = importPreview ? importPreview.errors.length > 0 : false; const previewContent = selectedFile && importPreview ? (importPreview.files[selectedFile] ?? null) : null; const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null; if (!selectedCompanyId) { return ; } return (
{/* Source form section */}

Import source

Choose a GitHub repo or upload a local Paperclip zip package.

{( [ { key: "github", icon: Github, label: "GitHub repo" }, { key: "local", icon: Upload, label: "Local zip" }, ] as const ).map(({ key, icon: Icon, label }) => ( ))}
{sourceMode === "local" ? (
{localPackage && ( {localPackage.name} with{" "} {Object.keys(localPackage.files).length} file {Object.keys(localPackage.files).length === 1 ? "" : "s"} )}
{!localPackage && (

Upload a `.zip` exported from Paperclip that contains COMPANY.md and the related package files.

)}
) : ( { setImportUrl(e.target.value); setImportPreview(null); }} /> )} {targetMode === "new" && ( setNewCompanyName(e.target.value)} placeholder="Imported Company" /> )}
{/* Preview results */} {importPreview && ( <> {/* Sticky import action bar */}
Import preview {selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected {conflicts.length > 0 && ( {conflicts.length} rename{conflicts.length === 1 ? "" : "s"} )} {importPreview.errors.length > 0 && ( {importPreview.errors.length} error{importPreview.errors.length === 1 ? "" : "s"} )}
{/* Conflict resolution list */} {/* Import button — below renames */}
{/* Warnings */} {importPreview.warnings.length > 0 && (
{importPreview.warnings.map((w) => (
{w}
))}
)} {/* Errors */} {importPreview.errors.length > 0 && (
{importPreview.errors.map((e) => (
{e}
))}
)} {/* Two-column layout */}
)}
); }