Merge pull request #1655 from paperclipai/pr/pap-795-company-portability

feat(portability): improve company import and export flow
This commit is contained in:
Dotta 2026-03-23 19:45:05 -05:00 committed by GitHub
commit eeb7e1a91a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 5238 additions and 271 deletions

View file

@ -1,22 +1,32 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
Agent,
CompanyPortabilityFileEntry,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityManifest,
Project,
} from "@paperclipai/shared";
import { useNavigate, useLocation } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { projectsApi } from "../api/projects";
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 { queryKeys } from "../lib/queryKeys";
import { createZipArchive } from "../lib/zip";
import { buildInitialExportCheckedFiles } from "../lib/company-export-selection";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { buildPortableSidebarOrder } from "../lib/company-portability-sidebar";
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
import {
Download,
@ -34,11 +44,6 @@ import {
PackageFileTree,
} from "../components/PackageFileTree";
/** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */
function isTaskPath(filePath: string): boolean {
return /(?:^|\/)tasks\//.test(filePath);
}
/**
* Extract the set of agent/project/task slugs that are "checked" based on
* which file paths are in the checked set.
@ -50,6 +55,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
agents: Set<string>;
projects: Set<string>;
tasks: Set<string>;
routines: Set<string>;
} {
const agents = new Set<string>();
const projects = new Set<string>();
@ -62,7 +68,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
const taskMatch = p.match(/^tasks\/([^/]+)\//);
if (taskMatch) tasks.add(taskMatch[1]);
}
return { agents, projects, tasks };
return { agents, projects, tasks, routines: new Set(tasks) };
}
/**
@ -77,16 +83,30 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
const out: string[] = [];
// Sections whose entries are slug-keyed and should be filtered
const filterableSections = new Set(["agents", "projects", "tasks"]);
const filterableSections = new Set(["agents", "projects", "tasks", "routines"]);
const sidebarSections = new Set(["agents", "projects"]);
let currentSection: string | null = null; // top-level key (e.g. "agents")
let currentEntry: string | null = null; // slug under that section
let includeEntry = true;
let currentSidebarList: string | null = null;
let currentSidebarHeaderLine: string | null = null;
let currentSidebarBuffer: string[] = [];
// Collect entries per section so we can omit empty section headers
let sectionHeaderLine: string | null = null;
let sectionBuffer: string[] = [];
function flushSidebarSection() {
if (currentSidebarHeaderLine !== null && currentSidebarBuffer.length > 0) {
sectionBuffer.push(currentSidebarHeaderLine);
sectionBuffer.push(...currentSidebarBuffer);
}
currentSidebarHeaderLine = null;
currentSidebarBuffer = [];
}
function flushSection() {
flushSidebarSection();
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
out.push(sectionHeaderLine);
out.push(...sectionBuffer);
@ -109,6 +129,11 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
currentSection = key;
sectionHeaderLine = line;
continue;
} else if (key === "sidebar") {
currentSection = key;
currentSidebarList = null;
sectionHeaderLine = line;
continue;
} else {
currentSection = null;
out.push(line);
@ -116,6 +141,32 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
}
}
if (currentSection === "sidebar") {
const sidebarMatch = line.match(/^ ([\w-]+):\s*$/);
if (sidebarMatch && !line.startsWith(" ")) {
flushSidebarSection();
const sidebarKey = sidebarMatch[1];
currentSidebarList = sidebarKey && sidebarSections.has(sidebarKey) ? sidebarKey : null;
currentSidebarHeaderLine = currentSidebarList ? line : null;
continue;
}
const sidebarEntryMatch = line.match(/^ - ["']?([^"'\n]+)["']?\s*$/);
if (sidebarEntryMatch && currentSidebarList) {
const slug = sidebarEntryMatch[1];
const sectionSlugs = slugs[currentSidebarList as keyof typeof slugs];
if (slug && sectionSlugs.has(slug)) {
currentSidebarBuffer.push(line);
}
continue;
}
if (currentSidebarList) {
currentSidebarBuffer.push(line);
continue;
}
}
// Inside a filterable section
if (currentSection && filterableSections.has(currentSection)) {
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
@ -532,6 +583,20 @@ export function CompanyExport() {
const { pushToast } = useToast();
const navigate = useNavigate();
const location = useLocation();
const { data: session, isFetched: isSessionFetched } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: agents = [], isFetched: areAgentsFetched } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects = [], isFetched: areProjectsFetched } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
@ -541,6 +606,38 @@ export function CompanyExport() {
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const savedExpandedRef = useRef<Set<string> | null>(null);
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const visibleAgents = useMemo(
() => agents.filter((agent: Agent) => agent.status !== "terminated"),
[agents],
);
const visibleProjects = useMemo(
() => projects.filter((project: Project) => !project.archivedAt),
[projects],
);
const { orderedAgents } = useAgentOrder({
agents: visibleAgents,
companyId: selectedCompanyId,
userId: currentUserId,
});
const { orderedProjects } = useProjectOrder({
projects: visibleProjects,
companyId: selectedCompanyId,
userId: currentUserId,
});
const sidebarOrder = useMemo(
() => buildPortableSidebarOrder({
agents: visibleAgents,
orderedAgents,
projects: visibleProjects,
orderedProjects,
}),
[orderedAgents, orderedProjects, visibleAgents, visibleProjects],
);
const sidebarOrderKey = useMemo(
() => JSON.stringify(sidebarOrder ?? null),
[sidebarOrder],
);
// Navigate-aware file selection: updates state + URL without page reload.
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
@ -584,17 +681,17 @@ export function CompanyExport() {
mutationFn: () =>
companiesApi.exportPreview(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: true },
sidebarOrder,
}),
onSuccess: (result) => {
setExportData(result);
setCheckedFiles((prev) => {
const next = new Set<string>();
for (const filePath of Object.keys(result.files)) {
if (prev.has(filePath)) next.add(filePath);
else if (!isTaskPath(filePath)) next.add(filePath);
}
return next;
});
setCheckedFiles((prev) =>
buildInitialExportCheckedFiles(
Object.keys(result.files),
result.manifest.issues,
prev,
),
);
// Expand top-level dirs (except tasks — collapsed by default)
const tree = buildFileTree(result.files);
const topDirs = new Set<string>();
@ -633,6 +730,7 @@ export function CompanyExport() {
companiesApi.exportPackage(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: true },
selectedFiles: Array.from(checkedFiles).sort(),
sidebarOrder,
}),
onSuccess: (result) => {
const resultCheckedFiles = new Set(Object.keys(result.files));
@ -654,10 +752,11 @@ export function CompanyExport() {
useEffect(() => {
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
if (!isSessionFetched || !areAgentsFetched || !areProjectsFetched) return;
setExportData(null);
exportPreviewMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCompanyId]);
}, [selectedCompanyId, isSessionFetched, areAgentsFetched, areProjectsFetched, sidebarOrderKey]);
const tree = useMemo(
() => (exportData ? buildFileTree(exportData.files) : []),

View file

@ -10,9 +10,12 @@ import type {
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys";
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order";
import { MarkdownBody } from "../components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
@ -342,6 +345,45 @@ function prefixedName(prefix: string | null, originalName: string): string {
return `${prefix}-${originalName}`;
}
function applyImportedSidebarOrder(
preview: CompanyPortabilityPreviewResult | null,
result: {
company: { id: string };
agents: Array<{ slug: string; id: string | null }>;
projects: Array<{ slug: string; id: string | null }>;
},
userId: string | null | undefined,
) {
const sidebar = preview?.manifest.sidebar;
if (!sidebar) return;
if (!userId?.trim()) return;
const agentIdBySlug = new Map(
result.agents
.filter((agent): agent is { slug: string; id: string } => typeof agent.id === "string" && agent.id.length > 0)
.map((agent) => [agent.slug, agent.id]),
);
const projectIdBySlug = new Map(
result.projects
.filter((project): project is { slug: string; id: string } => typeof project.id === "string" && project.id.length > 0)
.map((project) => [project.slug, project.id]),
);
const orderedAgentIds = sidebar.agents
.map((slug) => agentIdBySlug.get(slug))
.filter((id): id is string => Boolean(id));
const orderedProjectIds = sidebar.projects
.map((slug) => projectIdBySlug.get(slug))
.filter((id): id is string => Boolean(id));
if (orderedAgentIds.length > 0) {
writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds);
}
if (orderedProjectIds.length > 0) {
writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds);
}
}
// ── Conflict resolution UI ───────────────────────────────────────────
function ConflictResolutionList({
@ -611,6 +653,11 @@ export function CompanyImport() {
const { pushToast } = useToast();
const queryClient = useQueryClient();
const packageInputRef = useRef<HTMLInputElement | null>(null);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
// Source state
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
@ -800,6 +847,18 @@ export function CompanyImport() {
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
const importedCompany = await companiesApi.get(result.company.id);
const refreshedSession = currentUserId
? null
: await queryClient.fetchQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const sidebarOrderUserId =
currentUserId
?? refreshedSession?.user?.id
?? refreshedSession?.session?.userId
?? null;
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
setSelectedCompanyId(importedCompany.id);
pushToast({
tone: "success",