Move company import/export to dedicated pages with file-browser UX

- Add /:company/company/export page with file tree, checkboxes for
  per-file selection, and read-only preview pane (skills-style layout)
- Add /:company/company/import page with source form (GitHub/URL/local),
  target/collision settings, preview tree with action badges, and detail pane
- Add Import/Export buttons to the Org Chart page header
- Replace import/export sections in CompanySettings with redirect links
- Clean up ~800 lines of dead code from CompanySettings
- Register new routes in App.tsx

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-15 14:43:07 -05:00
parent eb647ab2db
commit 76d30ff835
5 changed files with 1285 additions and 788 deletions

View File

@@ -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() {
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/export" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />

View File

@@ -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<string, string>): 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<string> {
const paths = new Set<string>();
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<string, string>, 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<string>) {
const filteredFiles: Record<string, string> = {};
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<string>;
checkedFiles: Set<string>;
onToggleDir: (path: string) => void;
onSelectFile: (path: string) => void;
onToggleCheck: (path: string, kind: "file" | "dir") => void;
depth?: number;
}) {
return (
<div>
{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 (
<div key={node.path}>
<div
className={cn(
"group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
TREE_ROW_HEIGHT_CLASS,
)}
>
<label
className="flex items-center pl-2"
style={{ paddingLeft: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px` }}
>
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
onChange={() => onToggleCheck(node.path, "dir")}
className="mr-2 accent-foreground"
/>
</label>
<button
type="button"
className="flex min-w-0 items-center gap-2 py-1 text-left"
onClick={() => onToggleDir(node.path)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{expanded ? (
<FolderOpen className="h-3.5 w-3.5" />
) : (
<Folder className="h-3.5 w-3.5" />
)}
</span>
<span className="truncate">{node.name}</span>
</button>
<button
type="button"
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100"
onClick={() => onToggleDir(node.path)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
</div>
{expanded && (
<ExportFileTree
nodes={node.children}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={checkedFiles}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onToggleCheck={onToggleCheck}
depth={depth + 1}
/>
)}
</div>
);
}
const FileIcon = fileIcon(node.name);
const checked = checkedFiles.has(node.path);
return (
<div
key={node.path}
className={cn(
"flex w-full items-center gap-2 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
TREE_ROW_HEIGHT_CLASS,
node.path === selectedFile && "text-foreground bg-accent/20",
)}
style={{
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}}
>
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={checked}
onChange={() => onToggleCheck(node.path, "file")}
className="mr-2 accent-foreground"
/>
</label>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
onClick={() => onSelectFile(node.path)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<FileIcon className="h-3.5 w-3.5" />
</span>
<span className="truncate">{node.name}</span>
</button>
</div>
);
})}
</div>
);
}
// ── Preview pane ──────────────────────────────────────────────────────
function ExportPreviewPane({
selectedFile,
content,
}: {
selectedFile: string | null;
content: string | null;
}) {
if (!selectedFile || content === null) {
return (
<EmptyState icon={Package} message="Select a file to preview its contents." />
);
}
const isMarkdown = selectedFile.endsWith(".md");
return (
<div className="min-w-0">
<div className="border-b border-border px-5 py-3">
<div className="truncate font-mono text-sm">{selectedFile}</div>
</div>
<div className="min-h-[560px] px-5 py-5">
{isMarkdown ? (
<MarkdownBody>{content}</MarkdownBody>
) : (
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
<code>{content}</code>
</pre>
)}
</div>
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────
export function CompanyExport() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const [exportData, setExportData] = useState<CompanyPortabilityExportResult | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(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<string>();
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 <EmptyState icon={Package} message="Select a company to export." />;
}
if (exportMutation.isPending && !exportData) {
return <PageSkeleton variant="detail" />;
}
if (!exportData) {
return <EmptyState icon={Package} message="Loading export data..." />;
}
const previewContent = selectedFile ? (exportData.files[selectedFile] ?? null) : null;
return (
<div>
{/* Sticky top action bar */}
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4 text-sm">
<span className="font-medium">
{selectedCompany?.name ?? "Company"} export
</span>
<span className="text-muted-foreground">
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
</span>
{exportData.warnings.length > 0 && (
<span className="text-amber-600">
{exportData.warnings.length} warning{exportData.warnings.length === 1 ? "" : "s"}
</span>
)}
</div>
<Button
size="sm"
onClick={handleDownload}
disabled={selectedCount === 0}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Export {selectedCount} file{selectedCount === 1 ? "" : "s"}
</Button>
</div>
</div>
{/* Warnings */}
{exportData.warnings.length > 0 && (
<div className="border-b border-amber-300/60 bg-amber-50/60 px-5 py-2">
{exportData.warnings.map((w) => (
<div key={w} className="text-xs text-amber-700">{w}</div>
))}
</div>
)}
{/* Two-column layout */}
<div className="grid min-h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="border-r border-border">
<div className="border-b border-border px-4 py-3">
<h2 className="text-base font-semibold">Package files</h2>
<p className="text-xs text-muted-foreground">
{totalFiles} file{totalFiles === 1 ? "" : "s"} in {exportData.rootPath}
</p>
</div>
<ExportFileTree
nodes={tree}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={checkedFiles}
onToggleDir={handleToggleDir}
onSelectFile={setSelectedFile}
onToggleCheck={handleToggleCheck}
/>
</aside>
<div className="min-w-0 pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} />
</div>
</div>
</div>
);
}

View File

@@ -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<string>;
onToggleSection: (name: string) => void;
onSelectItem: (name: string) => void;
depth?: number;
}) {
return (
<div>
{nodes.map((node) => {
if (node.kind === "section") {
const expanded = expandedSections.has(node.name);
return (
<div key={node.name}>
<button
type="button"
className={cn(
"group flex w-full items-center gap-2 pr-3 text-left text-sm font-medium text-muted-foreground hover:bg-accent/30 hover:text-foreground",
TREE_ROW_HEIGHT_CLASS,
)}
style={{ paddingLeft: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT}px` }}
onClick={() => onToggleSection(node.name)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
<span className="truncate">{node.name}</span>
</button>
{expanded && (
<ImportPreviewTree
nodes={node.children}
selectedItem={selectedItem}
expandedSections={expandedSections}
onToggleSection={onToggleSection}
onSelectItem={onSelectItem}
depth={depth + 1}
/>
)}
</div>
);
}
return (
<button
key={node.name}
type="button"
className={cn(
"flex w-full items-center gap-2 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
TREE_ROW_HEIGHT_CLASS,
node.name === selectedItem && "text-foreground bg-accent/20",
)}
style={{ paddingLeft: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT}px` }}
onClick={() => onSelectItem(node.name)}
>
<span className="flex-1 truncate">{node.name}</span>
{node.action && (
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
{node.action}
</span>
)}
</button>
);
})}
</div>
);
}
// ── Import detail pane ────────────────────────────────────────────────
function ImportDetailPane({
selectedItem,
previewTree,
}: {
selectedItem: string | null;
previewTree: PreviewTreeNode[];
}) {
if (!selectedItem) {
return (
<EmptyState icon={Download} message="Select an item to see its details." />
);
}
// 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 (
<EmptyState icon={Download} message="Item not found." />
);
}
return (
<div className="min-w-0">
<div className="border-b border-border px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="truncate text-lg font-semibold">{found.name}</h2>
</div>
{found.action && (
<span className="shrink-0 rounded-full border border-border px-3 py-1 text-xs uppercase tracking-wide text-muted-foreground">
{found.action}
</span>
)}
</div>
</div>
<div className="px-5 py-5 space-y-3">
{found.detail && (
<div className="text-sm text-muted-foreground">{found.detail}</div>
)}
{found.reason && (
<div className="text-sm">{found.reason}</div>
)}
</div>
</div>
);
}
// ── Helpers ───────────────────────────────────────────────────────────
async function readLocalPackageSelection(fileList: FileList): Promise<{
rootPath: string | null;
files: Record<string, string>;
}> {
const files: Record<string, string> = {};
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<HTMLInputElement | null>(null);
// Source state
const [sourceMode, setSourceMode] = useState<"github" | "url" | "local">("github");
const [importUrl, setImportUrl] = useState("");
const [localPackage, setLocalPackage] = useState<{
rootPath: string | null;
files: Record<string, string>;
} | null>(null);
// Target state
const [targetMode, setTargetMode] = useState<"existing" | "new">("existing");
const [collisionStrategy, setCollisionStrategy] =
useState<CompanyPortabilityCollisionStrategy>("rename");
const [newCompanyName, setNewCompanyName] = useState("");
// Preview state
const [importPreview, setImportPreview] =
useState<CompanyPortabilityPreviewResult | null>(null);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(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<HTMLInputElement>) {
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 <EmptyState icon={Download} message="Select a company to import into." />;
}
return (
<div>
{/* Source form section */}
<div className="border-b border-border px-5 py-5 space-y-4">
<div>
<h2 className="text-base font-semibold">Import source</h2>
<p className="text-xs text-muted-foreground mt-1">
Choose a GitHub repo, direct URL, or local folder to import from.
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
{(
[
{ 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 }) => (
<button
key={key}
type="button"
className={cn(
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
sourceMode === key
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50",
)}
onClick={() => setSourceMode(key)}
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{label}
</div>
</button>
))}
</div>
{sourceMode === "local" ? (
<div className="rounded-md border border-dashed border-border px-3 py-3">
<input
ref={packageInputRef}
type="file"
multiple
className="hidden"
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
webkitdirectory=""
onChange={handleChooseLocalPackage}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => packageInputRef.current?.click()}
>
Choose folder
</Button>
{localPackage && (
<span className="text-xs text-muted-foreground">
{localPackage.rootPath ?? "package"} with{" "}
{Object.keys(localPackage.files).length} file
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
</span>
)}
</div>
{!localPackage && (
<p className="mt-2 text-xs text-muted-foreground">
Select a folder that contains COMPANY.md and any referenced AGENTS.md files.
</p>
)}
</div>
) : (
<Field
label={sourceMode === "github" ? "GitHub URL" : "Package URL"}
hint={
sourceMode === "github"
? "Repo root, tree path, or blob URL to COMPANY.md."
: "Point directly at COMPANY.md or a directory that contains it."
}
>
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={importUrl}
placeholder={
sourceMode === "github"
? "https://github.com/owner/repo/tree/main/company"
: "https://example.com/company/COMPANY.md"
}
onChange={(e) => {
setImportUrl(e.target.value);
setImportPreview(null);
}}
/>
</Field>
)}
<div className="grid gap-3 md:grid-cols-2">
<Field label="Target" hint="Import into this company or create a new one.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={targetMode}
onChange={(e) => {
setTargetMode(e.target.value as "existing" | "new");
setImportPreview(null);
}}
>
<option value="existing">
Existing company: {selectedCompany?.name}
</option>
<option value="new">Create new company</option>
</select>
</Field>
<Field
label="Collision strategy"
hint="Controls what happens when imported agent slugs already exist."
>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={collisionStrategy}
onChange={(e) => {
setCollisionStrategy(e.target.value as CompanyPortabilityCollisionStrategy);
setImportPreview(null);
}}
>
<option value="rename">Rename imported agents</option>
<option value="skip">Skip existing agents</option>
<option value="replace">Replace existing agents</option>
</select>
</Field>
</div>
{targetMode === "new" && (
<Field
label="New company name"
hint="Optional override. Leave blank to use the package name."
>
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
placeholder="Imported Company"
/>
</Field>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending || !hasSource}
>
{previewMutation.isPending ? "Previewing..." : "Preview import"}
</Button>
</div>
</div>
{/* Preview results */}
{importPreview && (
<>
{/* Sticky import action bar */}
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4 text-sm">
<span className="font-medium">
Import preview
</span>
<span className="text-muted-foreground">
Target: {importPreview.targetCompanyName ?? "new company"}
</span>
<span className="text-muted-foreground">
Strategy: {importPreview.collisionStrategy}
</span>
{importPreview.warnings.length > 0 && (
<span className="text-amber-600">
{importPreview.warnings.length} warning{importPreview.warnings.length === 1 ? "" : "s"}
</span>
)}
{importPreview.errors.length > 0 && (
<span className="text-destructive">
{importPreview.errors.length} error{importPreview.errors.length === 1 ? "" : "s"}
</span>
)}
</div>
<Button
size="sm"
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending || hasErrors}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
{importMutation.isPending ? "Importing..." : "Apply import"}
</Button>
</div>
</div>
{/* Warnings */}
{importPreview.warnings.length > 0 && (
<div className="border-b border-amber-300/60 bg-amber-50/60 px-5 py-2">
{importPreview.warnings.map((w) => (
<div key={w} className="text-xs text-amber-700">{w}</div>
))}
</div>
)}
{/* Errors */}
{importPreview.errors.length > 0 && (
<div className="border-b border-destructive/40 bg-destructive/5 px-5 py-2">
{importPreview.errors.map((e) => (
<div key={e} className="text-xs text-destructive">{e}</div>
))}
</div>
)}
{/* Two-column layout */}
<div className="grid min-h-[calc(100vh-16rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="border-r border-border">
<div className="border-b border-border px-4 py-3">
<h2 className="text-base font-semibold">Import plan</h2>
<p className="text-xs text-muted-foreground">
{importPreview.plan.agentPlans.length} agent{importPreview.plan.agentPlans.length === 1 ? "" : "s"},
{" "}{importPreview.plan.projectPlans.length} project{importPreview.plan.projectPlans.length === 1 ? "" : "s"},
{" "}{importPreview.plan.issuePlans.length} task{importPreview.plan.issuePlans.length === 1 ? "" : "s"}
</p>
</div>
<ImportPreviewTree
nodes={previewTree}
selectedItem={selectedItem}
expandedSections={expandedSections}
onToggleSection={(name) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}}
onSelectItem={setSelectedItem}
/>
</aside>
<div className="min-w-0 pl-6">
<ImportDetailPane
selectedItem={selectedItem}
previewTree={previewTree}
/>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -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<HTMLInputElement | null>(null);
// General settings local state
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
@@ -57,18 +48,6 @@ export function CompanySettings() {
const [inviteSnippet, setInviteSnippet] = useState<string | null>(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<CompanyPortabilityCollisionStrategy>("rename");
const [localPackage, setLocalPackage] = useState<{
rootPath: string | null;
files: Record<string, string>;
} | null>(null);
const [importPreview, setImportPreview] = useState<CompanyPortabilityPreviewResult | null>(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<CompanyPortabilitySource | null>(() => {
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<CompanyPortabilityPreviewRequest | null>(() => {
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<HTMLInputElement>
) {
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 (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-2">
@@ -628,421 +386,25 @@ export function CompanySettings() {
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Company Packages
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="text-sm font-medium">Export markdown package</div>
<p className="text-xs text-muted-foreground">
Download a markdown-first company package as a single tar file.
</p>
</div>
<Button
size="sm"
onClick={() => exportMutation.mutate()}
disabled={
exportMutation.isPending ||
(!packageIncludeCompany && !packageIncludeAgents)
}
>
<Download className="mr-1 h-3.5 w-3.5" />
{exportMutation.isPending ? "Exporting..." : "Export package"}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
checked={packageIncludeCompany}
onChange={(e) => setPackageIncludeCompany(e.target.checked)}
/>
Include company metadata
</label>
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
checked={packageIncludeAgents}
onChange={(e) => setPackageIncludeAgents(e.target.checked)}
/>
Include agents
</label>
</div>
<p className="text-xs text-muted-foreground">
Export always includes `.paperclip.yaml` as a Paperclip sidecar while keeping the markdown package readable and shareable.
<div className="rounded-md border border-border px-4 py-4">
<p className="text-sm text-muted-foreground">
Import and export have moved to dedicated pages accessible from the{" "}
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
</p>
{exportMutation.data && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Last export
</div>
<div className="mt-2 text-sm">
{exportMutation.data.rootPath}.tar with{" "}
{Object.keys(exportMutation.data.files).length} file
{Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}. Includes{" "}
<span className="font-mono">{exportMutation.data.paperclipExtensionPath}</span>.
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{Object.keys(exportMutation.data.files).map((filePath) => (
<span
key={filePath}
className="rounded-full border border-border px-2 py-0.5"
>
{filePath}
</span>
))}
</div>
{exportMutation.data.warnings.length > 0 && (
<div className="mt-3 space-y-1 text-xs text-amber-700">
{exportMutation.data.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
)}
</div>
)}
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium">Import company package</div>
<p className="text-xs text-muted-foreground">
Preview a GitHub repo, direct COMPANY.md URL, or local folder before applying it.
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
<button
type="button"
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "github"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
}`}
onClick={() => setImportSourceMode("github")}
>
<div className="flex items-center gap-2">
<Github className="h-4 w-4" />
GitHub repo
</div>
</button>
<button
type="button"
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "url"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
}`}
onClick={() => setImportSourceMode("url")}
>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
Direct URL
</div>
</button>
<button
type="button"
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "local"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
}`}
onClick={() => setImportSourceMode("local")}
>
<div className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Local folder
</div>
</button>
</div>
{importSourceMode === "local" ? (
<div className="rounded-md border border-dashed border-border px-3 py-3">
<input
ref={packageInputRef}
type="file"
multiple
className="hidden"
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
webkitdirectory=""
onChange={handleChooseLocalPackage}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => packageInputRef.current?.click()}
>
Choose folder
</Button>
{localPackage && (
<span className="text-xs text-muted-foreground">
{localPackage.rootPath ?? "package"} with{" "}
{Object.keys(localPackage.files).length} package file
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
</span>
)}
</div>
{!localPackage && (
<p className="mt-2 text-xs text-muted-foreground">
Select a folder that contains COMPANY.md and any referenced
AGENTS.md files.
</p>
)}
</div>
) : (
<Field
label={importSourceMode === "github" ? "GitHub URL" : "Package URL"}
hint={
importSourceMode === "github"
? "Repo root, tree path, or blob URL to COMPANY.md. Unpinned refs warn but do not block."
: "Point directly at COMPANY.md or a directory that contains it."
}
>
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={importUrl}
placeholder={
importSourceMode === "github"
? "https://github.com/owner/repo/tree/main/company"
: "https://example.com/company/COMPANY.md"
}
onChange={(e) => setImportUrl(e.target.value)}
/>
</Field>
)}
<div className="grid gap-3 md:grid-cols-2">
<Field
label="Target"
hint="Import into this company or create a new one from the package."
>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={importTargetMode}
onChange={(e) =>
setImportTargetMode(e.target.value as "existing" | "new")
}
>
<option value="existing">
Existing company: {selectedCompany.name}
</option>
<option value="new">Create new company</option>
</select>
</Field>
<Field
label="Collision strategy"
hint="Controls what happens when imported agent slugs already exist."
>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={collisionStrategy}
onChange={(e) =>
setCollisionStrategy(
e.target.value as CompanyPortabilityCollisionStrategy
)
}
>
<option value="rename">Rename imported agents</option>
<option value="skip">Skip existing agents</option>
<option value="replace">Replace existing agents</option>
</select>
</Field>
</div>
{importTargetMode === "new" && (
<Field
label="New company name"
hint="Optional override. Leave blank to use the package name."
>
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
placeholder="Imported Company"
/>
</Field>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handlePreviewImport}
disabled={
previewImportMutation.isPending ||
(!packageIncludeCompany && !packageIncludeAgents)
}
>
{previewImportMutation.isPending ? "Previewing..." : "Preview import"}
<div className="mt-3 flex items-center gap-2">
<Button size="sm" variant="outline" asChild>
<a href="/company/export">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export
</a>
</Button>
<Button
size="sm"
onClick={handleApplyImport}
disabled={
importPackageMutation.isPending ||
previewImportMutation.isPending ||
!!(importPreview && importPreview.errors.length > 0) ||
(!packageIncludeCompany && !packageIncludeAgents)
}
>
{importPackageMutation.isPending ? "Importing..." : "Apply import"}
<Button size="sm" variant="outline" asChild>
<a href="/company/import">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import
</a>
</Button>
</div>
{importPreview && (
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-3">
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Company action
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.companyAction}
</div>
</div>
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Agent actions
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.agentPlans.length}
</div>
</div>
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Project actions
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.projectPlans.length}
</div>
</div>
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Task actions
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.issuePlans.length}
</div>
</div>
</div>
{importPreview.plan.agentPlans.length > 0 && (
<div className="space-y-2">
{importPreview.plan.agentPlans.map((agentPlan) => (
<div
key={agentPlan.slug}
className="rounded-md border border-border bg-background/70 px-3 py-2"
>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium">
{agentPlan.slug} {"->"} {agentPlan.plannedName}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
{agentPlan.action}
</span>
</div>
{agentPlan.reason && (
<div className="mt-1 text-xs text-muted-foreground">
{agentPlan.reason}
</div>
)}
</div>
))}
</div>
)}
{importPreview.plan.projectPlans.length > 0 && (
<div className="space-y-2">
{importPreview.plan.projectPlans.map((projectPlan) => (
<div
key={projectPlan.slug}
className="rounded-md border border-border bg-background/70 px-3 py-2"
>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium">
{projectPlan.slug} {"->"} {projectPlan.plannedName}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
{projectPlan.action}
</span>
</div>
{projectPlan.reason && (
<div className="mt-1 text-xs text-muted-foreground">
{projectPlan.reason}
</div>
)}
</div>
))}
</div>
)}
{importPreview.plan.issuePlans.length > 0 && (
<div className="space-y-2">
{importPreview.plan.issuePlans.map((issuePlan) => (
<div
key={issuePlan.slug}
className="rounded-md border border-border bg-background/70 px-3 py-2"
>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium">
{issuePlan.slug} {"->"} {issuePlan.plannedTitle}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
{issuePlan.action}
</span>
</div>
{issuePlan.reason && (
<div className="mt-1 text-xs text-muted-foreground">
{issuePlan.reason}
</div>
)}
</div>
))}
</div>
)}
{importPreview.envInputs.length > 0 && (
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Environment inputs
</div>
{importPreview.envInputs.map((inputValue) => (
<div
key={`${inputValue.agentSlug ?? "company"}:${inputValue.key}`}
className="text-xs text-muted-foreground"
>
{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" : ""}
</div>
))}
</div>
)}
{importPreview.warnings.length > 0 && (
<div className="space-y-1 rounded-md border border-amber-300/60 bg-amber-50/60 px-3 py-2 text-xs text-amber-700">
{importPreview.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
)}
{importPreview.errors.length > 0 && (
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{importPreview.errors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
)}
</div>
)}
</div>
</div>
@@ -1102,135 +464,6 @@ export function CompanySettings() {
);
}
async function readLocalPackageSelection(fileList: FileList): Promise<{
rootPath: string | null;
files: Record<string, string>;
}> {
const files: Record<string, string> = {};
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<void> {
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<string, string>,
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);

View File

@@ -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 (
<>
<div className="mb-4 flex items-center justify-end gap-2">
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import company
</Button>
</Link>
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export company
</Button>
</Link>
</div>
<div
ref={containerRef}
className="w-full h-[calc(100vh-4rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
className="w-full h-[calc(100vh-7rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
style={{ cursor: dragging ? "grabbing" : "grab" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -419,10 +435,11 @@ export function OrgChart() {
})}
</div>
</div>
</>
);
}
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
const roleLabels: Record<string, string> = AGENT_ROLE_LABELS;
function roleLabel(role: string): string {
return roleLabels[role] ?? role;