mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Merge pull request #1655 from paperclipai/pr/pap-795-company-portability
feat(portability): improve company import and export flow
This commit is contained in:
commit
eeb7e1a91a
36 changed files with 5238 additions and 271 deletions
|
|
@ -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) : []),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue