diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 7e028351..4223e0aa 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,8 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanySkills } from "./pages/CompanySkills"; +import { CompanyExport } from "./pages/CompanyExport"; +import { CompanyImport } from "./pages/CompanyImport"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceSettings } from "./pages/InstanceSettings"; import { PluginManager } from "./pages/PluginManager"; @@ -115,6 +117,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx new file mode 100644 index 00000000..93f65699 --- /dev/null +++ b/ui/src/pages/CompanyExport.tsx @@ -0,0 +1,543 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import type { CompanyPortabilityExportResult } from "@paperclipai/shared"; +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 { + ChevronDown, + ChevronRight, + Download, + FileCode2, + FileText, + Folder, + FolderOpen, + Package, +} from "lucide-react"; + +// ── Tree types ──────────────────────────────────────────────────────── + +type FileTreeNode = { + name: string; + path: string; + kind: "dir" | "file"; + children: FileTreeNode[]; +}; + +const TREE_BASE_INDENT = 16; +const TREE_STEP_INDENT = 24; +const TREE_ROW_HEIGHT_CLASS = "min-h-9"; + +// ── Helpers ─────────────────────────────────────────────────────────── + +function buildFileTree(files: Record): FileTreeNode[] { + const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] }; + + for (const filePath of Object.keys(files)) { + const segments = filePath.split("/").filter(Boolean); + let current = root; + let currentPath = ""; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const isLeaf = i === segments.length - 1; + let next = current.children.find((c) => c.name === segment); + if (!next) { + next = { + name: segment, + path: currentPath, + kind: isLeaf ? "file" : "dir", + children: [], + }; + current.children.push(next); + } + current = next; + } + } + + function sortNode(node: FileTreeNode) { + node.children.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + node.children.forEach(sortNode); + } + + sortNode(root); + return root.children; +} + +function countFiles(nodes: FileTreeNode[]): number { + let count = 0; + for (const node of nodes) { + if (node.kind === "file") count++; + else count += countFiles(node.children); + } + return count; +} + +function collectAllPaths( + nodes: FileTreeNode[], + type: "file" | "dir" | "all" = "all", +): Set { + const paths = new Set(); + for (const node of nodes) { + if (type === "all" || node.kind === type) paths.add(node.path); + for (const p of collectAllPaths(node.children, type)) paths.add(p); + } + return paths; +} + +function fileIcon(name: string) { + if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2; + return FileText; +} + +// ── Tar helpers (reused from CompanySettings) ───────────────────────── + +function createTarArchive(files: Record, rootPath: string): Uint8Array { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + for (const [relativePath, contents] of Object.entries(files)) { + const tarPath = `${rootPath}/${relativePath}`.replace(/\\/g, "/"); + const body = encoder.encode(contents); + chunks.push(buildTarHeader(tarPath, body.length)); + chunks.push(body); + const remainder = body.length % 512; + if (remainder > 0) chunks.push(new Uint8Array(512 - remainder)); + } + chunks.push(new Uint8Array(1024)); + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); + const archive = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + return archive; +} + +function buildTarHeader(pathname: string, size: number): Uint8Array { + const header = new Uint8Array(512); + writeTarString(header, 0, 100, pathname); + writeTarOctal(header, 100, 8, 0o644); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, size); + writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000)); + for (let i = 148; i < 156; i++) header[i] = 32; + header[156] = "0".charCodeAt(0); + writeTarString(header, 257, 6, "ustar"); + writeTarString(header, 263, 2, "00"); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + writeTarChecksum(header, checksum); + return header; +} + +function writeTarString(target: Uint8Array, offset: number, length: number, value: string) { + const encoded = new TextEncoder().encode(value); + target.set(encoded.slice(0, length), offset); +} + +function writeTarOctal(target: Uint8Array, offset: number, length: number, value: number) { + const stringValue = value.toString(8).padStart(length - 1, "0"); + writeTarString(target, offset, length - 1, stringValue); + target[offset + length - 1] = 0; +} + +function writeTarChecksum(target: Uint8Array, checksum: number) { + const stringValue = checksum.toString(8).padStart(6, "0"); + writeTarString(target, 148, 6, stringValue); + target[154] = 0; + target[155] = 32; +} + +function downloadTar(exported: CompanyPortabilityExportResult, selectedFiles: Set) { + const filteredFiles: Record = {}; + for (const [path, content] of Object.entries(exported.files)) { + if (selectedFiles.has(path)) filteredFiles[path] = content; + } + const tarBytes = createTarArchive(filteredFiles, exported.rootPath); + const tarBuffer = new ArrayBuffer(tarBytes.byteLength); + new Uint8Array(tarBuffer).set(tarBytes); + const blob = new Blob([tarBuffer], { type: "application/x-tar" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${exported.rootPath}.tar`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +// ── File tree component ─────────────────────────────────────────────── + +function ExportFileTree({ + nodes, + selectedFile, + expandedDirs, + checkedFiles, + onToggleDir, + onSelectFile, + onToggleCheck, + depth = 0, +}: { + nodes: FileTreeNode[]; + selectedFile: string | null; + expandedDirs: Set; + checkedFiles: Set; + onToggleDir: (path: string) => void; + onSelectFile: (path: string) => void; + onToggleCheck: (path: string, kind: "file" | "dir") => void; + depth?: number; +}) { + return ( +
+ {nodes.map((node) => { + const expanded = node.kind === "dir" && expandedDirs.has(node.path); + if (node.kind === "dir") { + const childFiles = collectAllPaths(node.children, "file"); + const allChecked = [...childFiles].every((p) => checkedFiles.has(p)); + const someChecked = [...childFiles].some((p) => checkedFiles.has(p)); + return ( +
+
+ + + +
+ {expanded && ( + + )} +
+ ); + } + + const FileIcon = fileIcon(node.name); + const checked = checkedFiles.has(node.path); + return ( +
+ + +
+ ); + })} +
+ ); +} + +// ── Preview pane ────────────────────────────────────────────────────── + +function ExportPreviewPane({ + selectedFile, + content, +}: { + selectedFile: string | null; + content: string | null; +}) { + if (!selectedFile || content === null) { + return ( + + ); + } + + const isMarkdown = selectedFile.endsWith(".md"); + + return ( +
+
+
{selectedFile}
+
+
+ {isMarkdown ? ( + {content} + ) : ( +
+            {content}
+          
+ )} +
+
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────── + +export function CompanyExport() { + const { selectedCompanyId, selectedCompany } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToast(); + + const [exportData, setExportData] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [checkedFiles, setCheckedFiles] = useState>(new Set()); + + useEffect(() => { + setBreadcrumbs([ + { label: "Org Chart", href: "/org" }, + { label: "Export" }, + ]); + }, [setBreadcrumbs]); + + // Load export data on mount + const exportMutation = useMutation({ + mutationFn: () => + companiesApi.exportBundle(selectedCompanyId!, { + include: { company: true, agents: true, projects: true, issues: true }, + }), + onSuccess: (result) => { + setExportData(result); + // Check all files by default + const allFiles = new Set(Object.keys(result.files)); + setCheckedFiles(allFiles); + // Expand top-level dirs + const tree = buildFileTree(result.files); + const topDirs = new Set(); + for (const node of tree) { + if (node.kind === "dir") topDirs.add(node.path); + } + setExpandedDirs(topDirs); + // Select first file + const firstFile = Object.keys(result.files)[0]; + if (firstFile) setSelectedFile(firstFile); + }, + onError: (err) => { + pushToast({ + tone: "error", + title: "Export failed", + body: err instanceof Error ? err.message : "Failed to load export data.", + }); + }, + }); + + useEffect(() => { + if (selectedCompanyId && !exportData && !exportMutation.isPending) { + exportMutation.mutate(); + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCompanyId]); + + const tree = useMemo( + () => (exportData ? buildFileTree(exportData.files) : []), + [exportData], + ); + + 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 (!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 handleDownload() { + if (!exportData) return; + downloadTar(exportData, checkedFiles); + pushToast({ + tone: "success", + title: "Export downloaded", + body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.tar`, + }); + } + + if (!selectedCompanyId) { + return ; + } + + if (exportMutation.isPending && !exportData) { + return ; + } + + if (!exportData) { + return ; + } + + const previewContent = selectedFile ? (exportData.files[selectedFile] ?? null) : null; + + return ( +
+ {/* Sticky top action bar */} +
+
+
+ + {selectedCompany?.name ?? "Company"} export + + + {selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected + + {exportData.warnings.length > 0 && ( + + {exportData.warnings.length} warning{exportData.warnings.length === 1 ? "" : "s"} + + )} +
+ +
+
+ + {/* Warnings */} + {exportData.warnings.length > 0 && ( +
+ {exportData.warnings.map((w) => ( +
{w}
+ ))} +
+ )} + + {/* Two-column layout */} +
+ +
+ +
+
+
+ ); +} diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx new file mode 100644 index 00000000..c95c82dd --- /dev/null +++ b/ui/src/pages/CompanyImport.tsx @@ -0,0 +1,700 @@ +import { useEffect, useRef, useState, type ChangeEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { + CompanyPortabilityCollisionStrategy, + 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 { Button } from "@/components/ui/button"; +import { EmptyState } from "../components/EmptyState"; +import { cn } from "../lib/utils"; +import { + ChevronDown, + ChevronRight, + Download, + Github, + Link2, + Upload, +} from "lucide-react"; +import { Field } from "../components/agent-config-primitives"; + +// ── Preview tree types ──────────────────────────────────────────────── + +type PreviewTreeNode = { + name: string; + kind: "section" | "item"; + action?: string; + reason?: string | null; + detail?: string; + children: PreviewTreeNode[]; +}; + +const TREE_BASE_INDENT = 16; +const TREE_STEP_INDENT = 24; +const TREE_ROW_HEIGHT_CLASS = "min-h-9"; + +// ── Build preview tree from preview result ──────────────────────────── + +function buildPreviewTree(preview: CompanyPortabilityPreviewResult): PreviewTreeNode[] { + const sections: PreviewTreeNode[] = []; + + // Company section + if (preview.plan.companyAction !== "none") { + sections.push({ + name: "Company", + kind: "section", + children: [ + { + name: preview.targetCompanyName ?? "New company", + kind: "item", + action: preview.plan.companyAction, + detail: `Target: ${preview.targetCompanyName ?? "new"}`, + children: [], + }, + ], + }); + } + + // Agents section + if (preview.plan.agentPlans.length > 0) { + sections.push({ + name: `Agents (${preview.plan.agentPlans.length})`, + kind: "section", + children: preview.plan.agentPlans.map((ap) => ({ + name: `${ap.slug} → ${ap.plannedName}`, + kind: "item" as const, + action: ap.action, + reason: ap.reason, + children: [], + })), + }); + } + + // Projects section + if (preview.plan.projectPlans.length > 0) { + sections.push({ + name: `Projects (${preview.plan.projectPlans.length})`, + kind: "section", + children: preview.plan.projectPlans.map((pp) => ({ + name: `${pp.slug} → ${pp.plannedName}`, + kind: "item" as const, + action: pp.action, + reason: pp.reason, + children: [], + })), + }); + } + + // Issues section + if (preview.plan.issuePlans.length > 0) { + sections.push({ + name: `Tasks (${preview.plan.issuePlans.length})`, + kind: "section", + children: preview.plan.issuePlans.map((ip) => ({ + name: `${ip.slug} → ${ip.plannedTitle}`, + kind: "item" as const, + action: ip.action, + reason: ip.reason, + children: [], + })), + }); + } + + // Env inputs section + if (preview.envInputs.length > 0) { + sections.push({ + name: `Environment inputs (${preview.envInputs.length})`, + kind: "section", + children: preview.envInputs.map((ei) => ({ + name: ei.key + (ei.agentSlug ? ` (${ei.agentSlug})` : ""), + kind: "item" as const, + action: ei.requirement, + detail: [ + ei.kind, + ei.requirement, + ei.defaultValue !== null ? `default: ${JSON.stringify(ei.defaultValue)}` : null, + ei.portability === "system_dependent" ? "system-dependent" : null, + ] + .filter(Boolean) + .join(" · "), + reason: ei.description, + children: [], + })), + }); + } + + return sections; +} + +// ── Preview tree component ──────────────────────────────────────────── + +function ImportPreviewTree({ + nodes, + selectedItem, + expandedSections, + onToggleSection, + onSelectItem, + depth = 0, +}: { + nodes: PreviewTreeNode[]; + selectedItem: string | null; + expandedSections: Set; + onToggleSection: (name: string) => void; + onSelectItem: (name: string) => void; + depth?: number; +}) { + return ( +
+ {nodes.map((node) => { + if (node.kind === "section") { + const expanded = expandedSections.has(node.name); + return ( +
+ + {expanded && ( + + )} +
+ ); + } + + return ( + + ); + })} +
+ ); +} + +// ── Import detail pane ──────────────────────────────────────────────── + +function ImportDetailPane({ + selectedItem, + previewTree, +}: { + selectedItem: string | null; + previewTree: PreviewTreeNode[]; +}) { + if (!selectedItem) { + return ( + + ); + } + + // Find the selected node + let found: PreviewTreeNode | null = null; + for (const section of previewTree) { + for (const child of section.children) { + if (child.name === selectedItem) { + found = child; + break; + } + } + if (found) break; + } + + if (!found) { + return ( + + ); + } + + return ( +
+
+
+
+

{found.name}

+
+ {found.action && ( + + {found.action} + + )} +
+
+
+ {found.detail && ( +
{found.detail}
+ )} + {found.reason && ( +
{found.reason}
+ )} +
+
+ ); +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +async function readLocalPackageSelection(fileList: FileList): Promise<{ + rootPath: string | null; + files: Record; +}> { + const files: Record = {}; + let rootPath: string | null = null; + for (const file of Array.from(fileList)) { + const relativePath = + (file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace( + /\\/g, + "/", + ) || file.name; + const isMarkdown = relativePath.endsWith(".md"); + const isPaperclipYaml = + relativePath.endsWith(".paperclip.yaml") || relativePath.endsWith(".paperclip.yml"); + if (!isMarkdown && !isPaperclipYaml) continue; + const topLevel = relativePath.split("/")[0] ?? null; + if (!rootPath && topLevel) rootPath = topLevel; + files[relativePath] = await file.text(); + } + if (Object.keys(files).length === 0) { + throw new Error("No package files were found in the selected folder."); + } + return { rootPath, 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" | "url" | "local">("github"); + const [importUrl, setImportUrl] = useState(""); + const [localPackage, setLocalPackage] = useState<{ + rootPath: string | null; + files: Record; + } | null>(null); + + // Target state + const [targetMode, setTargetMode] = useState<"existing" | "new">("existing"); + const [collisionStrategy, setCollisionStrategy] = + useState("rename"); + const [newCompanyName, setNewCompanyName] = useState(""); + + // Preview state + const [importPreview, setImportPreview] = + useState(null); + const [selectedItem, setSelectedItem] = useState(null); + const [expandedSections, setExpandedSections] = 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; + if (sourceMode === "github") return { type: "github", url }; + return { type: "url", 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, + }); + }, + onSuccess: (result) => { + setImportPreview(result); + // Expand all sections by default + const sections = buildPreviewTree(result).map((s) => s.name); + setExpandedSections(new Set(sections)); + setSelectedItem(null); + }, + onError: (err) => { + pushToast({ + tone: "error", + title: "Preview failed", + body: err instanceof Error ? err.message : "Failed to preview import.", + }); + }, + }); + + // 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, + }); + }, + 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(""); + }, + 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 readLocalPackageSelection(fileList); + setLocalPackage(pkg); + setImportPreview(null); + } catch (err) { + pushToast({ + tone: "error", + title: "Package read failed", + body: err instanceof Error ? err.message : "Failed to read folder.", + }); + } + } + + const previewTree = importPreview ? buildPreviewTree(importPreview) : []; + const hasSource = + sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0; + const hasErrors = importPreview ? importPreview.errors.length > 0 : false; + + if (!selectedCompanyId) { + return ; + } + + return ( +
+ {/* Source form section */} +
+
+

Import source

+

+ Choose a GitHub repo, direct URL, or local folder to import from. +

+
+ +
+ {( + [ + { key: "github", icon: Github, label: "GitHub repo" }, + { key: "url", icon: Link2, label: "Direct URL" }, + { key: "local", icon: Upload, label: "Local folder" }, + ] as const + ).map(({ key, icon: Icon, label }) => ( + + ))} +
+ + {sourceMode === "local" ? ( +
+ +
+ + {localPackage && ( + + {localPackage.rootPath ?? "package"} with{" "} + {Object.keys(localPackage.files).length} file + {Object.keys(localPackage.files).length === 1 ? "" : "s"} + + )} +
+ {!localPackage && ( +

+ Select a folder that contains COMPANY.md and any referenced AGENTS.md 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 + + + Target: {importPreview.targetCompanyName ?? "new company"} + + + Strategy: {importPreview.collisionStrategy} + + {importPreview.warnings.length > 0 && ( + + {importPreview.warnings.length} warning{importPreview.warnings.length === 1 ? "" : "s"} + + )} + {importPreview.errors.length > 0 && ( + + {importPreview.errors.length} error{importPreview.errors.length === 1 ? "" : "s"} + + )} +
+ +
+
+ + {/* Warnings */} + {importPreview.warnings.length > 0 && ( +
+ {importPreview.warnings.map((w) => ( +
{w}
+ ))} +
+ )} + + {/* Errors */} + {importPreview.errors.length > 0 && ( +
+ {importPreview.errors.map((e) => ( +
{e}
+ ))} +
+ )} + + {/* Two-column layout */} +
+ +
+ +
+
+ + )} +
+ ); +} diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 2770b99f..9aca6ca7 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -1,12 +1,5 @@ -import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { - CompanyPortabilityCollisionStrategy, - CompanyPortabilityExportResult, - CompanyPortabilityPreviewRequest, - CompanyPortabilityPreviewResult, - CompanyPortabilitySource, -} from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; @@ -14,7 +7,7 @@ import { companiesApi } from "../api/companies"; import { accessApi } from "../api/access"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; -import { Settings, Check, Download, Github, Link2, Upload } from "lucide-react"; +import { Settings, Check, Download, Upload } from "lucide-react"; import { CompanyPatternIcon } from "../components/CompanyPatternIcon"; import { Field, @@ -38,8 +31,6 @@ export function CompanySettings() { const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); - const packageInputRef = useRef(null); - // General settings local state const [companyName, setCompanyName] = useState(""); const [description, setDescription] = useState(""); @@ -57,18 +48,6 @@ export function CompanySettings() { const [inviteSnippet, setInviteSnippet] = useState(null); const [snippetCopied, setSnippetCopied] = useState(false); const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0); - const [packageIncludeCompany, setPackageIncludeCompany] = useState(true); - const [packageIncludeAgents, setPackageIncludeAgents] = useState(true); - const [importSourceMode, setImportSourceMode] = useState<"github" | "url" | "local">("github"); - const [importUrl, setImportUrl] = useState(""); - const [importTargetMode, setImportTargetMode] = useState<"existing" | "new">("existing"); - const [newCompanyName, setNewCompanyName] = useState(""); - const [collisionStrategy, setCollisionStrategy] = useState("rename"); - const [localPackage, setLocalPackage] = useState<{ - rootPath: string | null; - files: Record; - } | null>(null); - const [importPreview, setImportPreview] = useState(null); const generalDirty = !!selectedCompany && @@ -76,59 +55,6 @@ export function CompanySettings() { description !== (selectedCompany.description ?? "") || brandColor !== (selectedCompany.brandColor ?? "")); - const packageInclude = useMemo( - () => ({ - company: packageIncludeCompany, - agents: packageIncludeAgents, - projects: false, - issues: false - }), - [packageIncludeAgents, packageIncludeCompany] - ); - - const importSource = useMemo(() => { - if (importSourceMode === "local") { - if (!localPackage || Object.keys(localPackage.files).length === 0) return null; - return { - type: "inline", - rootPath: localPackage.rootPath, - files: localPackage.files - }; - } - const trimmed = importUrl.trim(); - if (!trimmed) return null; - return importSourceMode === "github" - ? { type: "github", url: trimmed } - : { type: "url", url: trimmed }; - }, [importSourceMode, importUrl, localPackage]); - - const importPayload = useMemo(() => { - if (!importSource) return null; - return { - source: importSource, - include: packageInclude, - target: - importTargetMode === "new" - ? { - mode: "new_company", - newCompanyName: newCompanyName.trim() || null - } - : { - mode: "existing_company", - companyId: selectedCompanyId! - }, - agents: "all", - collisionStrategy - }; - }, [ - collisionStrategy, - importSource, - importTargetMode, - newCompanyName, - packageInclude, - selectedCompanyId - ]); - const generalMutation = useMutation({ mutationFn: (data: { name: string; @@ -150,102 +76,6 @@ export function CompanySettings() { } }); - const exportMutation = useMutation({ - mutationFn: () => - companiesApi.exportBundle(selectedCompanyId!, { - include: packageInclude - }), - onSuccess: async (exported) => { - await downloadCompanyPackage(exported); - pushToast({ - tone: "success", - title: "Company package exported", - body: `${exported.rootPath}.tar downloaded with ${Object.keys(exported.files).length} file${Object.keys(exported.files).length === 1 ? "" : "s"}.` - }); - if (exported.warnings.length > 0) { - pushToast({ - tone: "warn", - title: "Export completed with warnings", - body: exported.warnings[0] - }); - } - }, - onError: (err) => { - pushToast({ - tone: "error", - title: "Export failed", - body: err instanceof Error ? err.message : "Failed to export company package" - }); - } - }); - - const previewImportMutation = useMutation({ - mutationFn: (payload: CompanyPortabilityPreviewRequest) => - companiesApi.importPreview(payload), - onSuccess: (preview) => { - setImportPreview(preview); - if (preview.errors.length > 0) { - pushToast({ - tone: "warn", - title: "Import preview found issues", - body: preview.errors[0] - }); - return; - } - pushToast({ - tone: "success", - title: "Import preview ready", - body: `${preview.plan.agentPlans.length} agent action${preview.plan.agentPlans.length === 1 ? "" : "s"} planned.` - }); - }, - onError: (err) => { - setImportPreview(null); - pushToast({ - tone: "error", - title: "Import preview failed", - body: err instanceof Error ? err.message : "Failed to preview company package" - }); - } - }); - - const importPackageMutation = useMutation({ - mutationFn: (payload: CompanyPortabilityPreviewRequest) => - companiesApi.importBundle(payload), - onSuccess: async (result) => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }), - queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }), - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(result.company.id) }), - queryClient.invalidateQueries({ queryKey: queryKeys.org(result.company.id) }) - ]); - if (importTargetMode === "new") { - setSelectedCompanyId(result.company.id); - } - pushToast({ - tone: "success", - title: "Company package imported", - body: `${result.agents.filter((agent) => agent.action !== "skipped").length} agent${result.agents.filter((agent) => agent.action !== "skipped").length === 1 ? "" : "s"} applied.` - }); - if (result.warnings.length > 0) { - pushToast({ - tone: "warn", - title: "Import completed with warnings", - body: result.warnings[0] - }); - } - setImportPreview(null); - setLocalPackage(null); - setImportUrl(""); - }, - onError: (err) => { - pushToast({ - tone: "error", - title: "Import failed", - body: err instanceof Error ? err.message : "Failed to import company package" - }); - } - }); - const inviteMutation = useMutation({ mutationFn: () => accessApi.createOpenClawInvitePrompt(selectedCompanyId!), @@ -306,20 +136,6 @@ export function CompanySettings() { setSnippetCopyDelightId(0); }, [selectedCompanyId]); - useEffect(() => { - setImportPreview(null); - }, [ - collisionStrategy, - importSourceMode, - importTargetMode, - importUrl, - localPackage, - newCompanyName, - packageIncludeAgents, - packageIncludeCompany, - selectedCompanyId - ]); - const archiveMutation = useMutation({ mutationFn: ({ companyId, @@ -364,64 +180,6 @@ export function CompanySettings() { }); } - async function handleChooseLocalPackage( - event: ChangeEvent - ) { - const selection = event.target.files; - if (!selection || selection.length === 0) { - setLocalPackage(null); - return; - } - try { - const parsed = await readLocalPackageSelection(selection); - setLocalPackage(parsed); - pushToast({ - tone: "success", - title: "Local package loaded", - body: `${Object.keys(parsed.files).length} package file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.` - }); - } catch (err) { - setLocalPackage(null); - pushToast({ - tone: "error", - title: "Failed to read local package", - body: err instanceof Error ? err.message : "Could not read selected files" - }); - } finally { - event.target.value = ""; - } - } - - function handlePreviewImport() { - if (!importPayload) { - pushToast({ - tone: "warn", - title: "Source required", - body: - importSourceMode === "local" - ? "Choose a local folder with COMPANY.md before previewing." - : "Enter a company package URL before previewing." - }); - return; - } - previewImportMutation.mutate(importPayload); - } - - function handleApplyImport() { - if (!importPayload) { - pushToast({ - tone: "warn", - title: "Source required", - body: - importSourceMode === "local" - ? "Choose a local folder with COMPANY.md before importing." - : "Enter a company package URL before importing." - }); - return; - } - importPackageMutation.mutate(importPayload); - } - return (
@@ -628,421 +386,25 @@ export function CompanySettings() {
Company Packages
- -
-
-
-
Export markdown package
-

- Download a markdown-first company package as a single tar file. -

-
- -
- -
- - -
-

- Export always includes `.paperclip.yaml` as a Paperclip sidecar while keeping the markdown package readable and shareable. +

+

+ Import and export have moved to dedicated pages accessible from the{" "} + Org Chart header.

- - {exportMutation.data && ( -
-
- Last export -
-
- {exportMutation.data.rootPath}.tar with{" "} - {Object.keys(exportMutation.data.files).length} file - {Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. Includes{" "} - {exportMutation.data.paperclipExtensionPath}. -
-
- {Object.keys(exportMutation.data.files).map((filePath) => ( - - {filePath} - - ))} -
- {exportMutation.data.warnings.length > 0 && ( -
- {exportMutation.data.warnings.map((warning) => ( -
{warning}
- ))} -
- )} -
- )} -
- -
-
-
Import company package
-

- Preview a GitHub repo, direct COMPANY.md URL, or local folder before applying it. -

-
- -
- - - -
- - {importSourceMode === "local" ? ( -
- -
- - {localPackage && ( - - {localPackage.rootPath ?? "package"} with{" "} - {Object.keys(localPackage.files).length} package file - {Object.keys(localPackage.files).length === 1 ? "" : "s"} - - )} -
- {!localPackage && ( -

- Select a folder that contains COMPANY.md and any referenced - AGENTS.md files. -

- )} -
- ) : ( - - setImportUrl(e.target.value)} - /> - - )} - -
- - - - - - -
- - {importTargetMode === "new" && ( - - setNewCompanyName(e.target.value)} - placeholder="Imported Company" - /> - - )} - -
- -
- - {importPreview && ( -
-
-
-
- Company action -
-
- {importPreview.plan.companyAction} -
-
-
-
- Agent actions -
-
- {importPreview.plan.agentPlans.length} -
-
-
-
- Project actions -
-
- {importPreview.plan.projectPlans.length} -
-
-
-
- Task actions -
-
- {importPreview.plan.issuePlans.length} -
-
-
- - {importPreview.plan.agentPlans.length > 0 && ( -
- {importPreview.plan.agentPlans.map((agentPlan) => ( -
-
- - {agentPlan.slug} {"->"} {agentPlan.plannedName} - - - {agentPlan.action} - -
- {agentPlan.reason && ( -
- {agentPlan.reason} -
- )} -
- ))} -
- )} - - {importPreview.plan.projectPlans.length > 0 && ( -
- {importPreview.plan.projectPlans.map((projectPlan) => ( -
-
- - {projectPlan.slug} {"->"} {projectPlan.plannedName} - - - {projectPlan.action} - -
- {projectPlan.reason && ( -
- {projectPlan.reason} -
- )} -
- ))} -
- )} - - {importPreview.plan.issuePlans.length > 0 && ( -
- {importPreview.plan.issuePlans.map((issuePlan) => ( -
-
- - {issuePlan.slug} {"->"} {issuePlan.plannedTitle} - - - {issuePlan.action} - -
- {issuePlan.reason && ( -
- {issuePlan.reason} -
- )} -
- ))} -
- )} - - {importPreview.envInputs.length > 0 && ( -
-
- Environment inputs -
- {importPreview.envInputs.map((inputValue) => ( -
- {inputValue.key} - {inputValue.agentSlug ? ` for ${inputValue.agentSlug}` : ""} - {` · ${inputValue.kind}`} - {` · ${inputValue.requirement}`} - {inputValue.defaultValue !== null ? ` · default ${JSON.stringify(inputValue.defaultValue)}` : ""} - {inputValue.portability === "system_dependent" ? " · system-dependent" : ""} -
- ))} -
- )} - - {importPreview.warnings.length > 0 && ( -
- {importPreview.warnings.map((warning) => ( -
{warning}
- ))} -
- )} - - {importPreview.errors.length > 0 && ( -
- {importPreview.errors.map((error) => ( -
{error}
- ))} -
- )} -
- )}
@@ -1102,135 +464,6 @@ export function CompanySettings() { ); } -async function readLocalPackageSelection(fileList: FileList): Promise<{ - rootPath: string | null; - files: Record; -}> { - const files: Record = {}; - let rootPath: string | null = null; - - for (const file of Array.from(fileList)) { - const relativePath = - (file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace( - /\\/g, - "/" - ) || file.name; - const isMarkdown = relativePath.endsWith(".md"); - const isPaperclipYaml = - relativePath.endsWith(".paperclip.yaml") || - relativePath.endsWith(".paperclip.yml"); - if (!isMarkdown && !isPaperclipYaml) continue; - const topLevel = relativePath.split("/")[0] ?? null; - if (!rootPath && topLevel) rootPath = topLevel; - files[relativePath] = await file.text(); - } - - if (Object.keys(files).length === 0) { - throw new Error("No package files were found in the selected folder."); - } - - return { rootPath, files }; -} - -async function downloadCompanyPackage( - exported: CompanyPortabilityExportResult -): Promise { - const tarBytes = createTarArchive(exported.files, exported.rootPath); - const tarBuffer = new ArrayBuffer(tarBytes.byteLength); - new Uint8Array(tarBuffer).set(tarBytes); - const blob = new Blob( - [tarBuffer], - { - type: "application/x-tar" - } - ); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${exported.rootPath}.tar`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); -} - -function createTarArchive( - files: Record, - rootPath: string -): Uint8Array { - const encoder = new TextEncoder(); - const chunks: Uint8Array[] = []; - - for (const [relativePath, contents] of Object.entries(files)) { - const tarPath = `${rootPath}/${relativePath}`.replace(/\\/g, "/"); - const body = encoder.encode(contents); - chunks.push(buildTarHeader(tarPath, body.length)); - chunks.push(body); - const remainder = body.length % 512; - if (remainder > 0) { - chunks.push(new Uint8Array(512 - remainder)); - } - } - - chunks.push(new Uint8Array(1024)); - - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const archive = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - archive.set(chunk, offset); - offset += chunk.length; - } - return archive; -} - -function buildTarHeader(pathname: string, size: number): Uint8Array { - const header = new Uint8Array(512); - writeTarString(header, 0, 100, pathname); - writeTarOctal(header, 100, 8, 0o644); - writeTarOctal(header, 108, 8, 0); - writeTarOctal(header, 116, 8, 0); - writeTarOctal(header, 124, 12, size); - writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000)); - for (let i = 148; i < 156; i += 1) { - header[i] = 32; - } - header[156] = "0".charCodeAt(0); - writeTarString(header, 257, 6, "ustar"); - writeTarString(header, 263, 2, "00"); - const checksum = header.reduce((sum, byte) => sum + byte, 0); - writeTarChecksum(header, checksum); - return header; -} - -function writeTarString( - target: Uint8Array, - offset: number, - length: number, - value: string -) { - const encoded = new TextEncoder().encode(value); - target.set(encoded.slice(0, length), offset); -} - -function writeTarOctal( - target: Uint8Array, - offset: number, - length: number, - value: number -) { - const stringValue = value.toString(8).padStart(length - 1, "0"); - writeTarString(target, offset, length - 1, stringValue); - target[offset + length - 1] = 0; -} - -function writeTarChecksum(target: Uint8Array, checksum: number) { - const stringValue = checksum.toString(8).padStart(6, "0"); - writeTarString(target, 148, 6, stringValue); - target[154] = 0; - target[155] = 32; -} - function buildAgentSnippet(input: AgentSnippetInput) { const candidateUrls = buildCandidateOnboardingUrls(input); const resolutionTestUrl = buildResolutionTestUrl(input); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 981545c0..b2d8b3b1 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -1,15 +1,16 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react"; -import { useNavigate } from "@/lib/router"; +import { Link, useNavigate } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { agentUrl } from "../lib/utils"; +import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; -import { Network } from "lucide-react"; +import { Download, Network, Upload } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; // Layout constants @@ -267,9 +268,24 @@ export function OrgChart() { } return ( + <> +
+ + + + + + +
+ ); } -const roleLabels = AGENT_ROLE_LABELS as Record; +const roleLabels: Record = AGENT_ROLE_LABELS; function roleLabel(role: string): string { return roleLabels[role] ?? role;