From ad494e74adb0b2d7026290fa4857c7c1ca1b8ae2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 09:21:48 -0500 Subject: [PATCH] feat: replace collision strategy dropdown with inline conflict resolution UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the collision strategy dropdown; always default to "rename" - Add a "Conflicts to resolve" chores list above the package file tree showing each collision with editable rename fields (oldname → newname) - Default rename uses source folder prefix (e.g. gstack-CEO) - Per-item "skip" button that syncs with file tree checkboxes - COMPANY.md defaults to skip when importing to an existing company - Add nameOverrides support to API types and server so user-edited renames are passed through to the import Co-Authored-By: Paperclip --- .../shared/src/types/company-portability.ts | 1 + .../src/validators/company-portability.ts | 1 + server/src/services/company-portability.ts | 22 ++ ui/src/pages/CompanyImport.tsx | 334 +++++++++++++++--- 4 files changed, 314 insertions(+), 44 deletions(-) diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 9e699b6a..a6b84097 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -149,6 +149,7 @@ export interface CompanyPortabilityPreviewRequest { target: CompanyPortabilityImportTarget; agents?: CompanyPortabilityAgentSelection; collisionStrategy?: CompanyPortabilityCollisionStrategy; + nameOverrides?: Record; } export interface CompanyPortabilityPreviewAgentPlan { diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index c4f20a51..900a108e 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -167,6 +167,7 @@ export const companyPortabilityPreviewSchema = z.object({ target: portabilityTargetSchema, agents: portabilityAgentSelectionSchema.optional(), collisionStrategy: portabilityCollisionStrategySchema.optional(), + nameOverrides: z.record(z.string().min(1), z.string().min(1)).optional(), }); export type CompanyPortabilityPreview = z.infer; diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 3cc3da37..206c06f8 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -2036,6 +2036,28 @@ export function companyPortabilityService(db: Db) { } } + // Apply user-specified name overrides (keyed by slug) + if (input.nameOverrides) { + for (const ap of agentPlans) { + const override = input.nameOverrides[ap.slug]; + if (override) { + ap.plannedName = override; + } + } + for (const pp of projectPlans) { + const override = input.nameOverrides[pp.slug]; + if (override) { + pp.plannedName = override; + } + } + for (const ip of issuePlans) { + const override = input.nameOverrides[ip.slug]; + if (override) { + ip.plannedTitle = override; + } + } + } + // Warn about agents that will be overwritten/updated for (const ap of agentPlans) { if (ap.action === "update") { diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index aa2ad210..74257d34 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { - CompanyPortabilityCollisionStrategy, CompanyPortabilityPreviewResult, CompanyPortabilitySource, } from "@paperclipai/shared"; @@ -15,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { cn } from "../lib/utils"; import { + ArrowRight, Download, Github, Link2, @@ -202,6 +202,181 @@ function ImportPreviewPane({ ); } +// ── Conflict item type ─────────────────────────────────────────────── + +interface ConflictItem { + slug: string; + kind: "agent" | "project" | "issue" | "company" | "skill"; + originalName: string; + plannedName: string; + filePath: string | null; + action: "rename" | "update"; +} + +function buildConflictList( + preview: CompanyPortabilityPreviewResult, + targetMode: "existing" | "new", +): ConflictItem[] { + const conflicts: ConflictItem[] = []; + const manifest = preview.manifest; + + // COMPANY.md when importing to existing company + if (targetMode === "existing" && manifest.company && preview.plan.companyAction === "update") { + conflicts.push({ + slug: "__company__", + kind: "company", + originalName: manifest.company.name, + plannedName: manifest.company.name, + filePath: ensureMarkdownPath(manifest.company.path), + action: "update", + }); + } + + // Agents with collisions + for (const ap of preview.plan.agentPlans) { + if (ap.existingAgentId) { + const agent = manifest.agents.find((a) => a.slug === ap.slug); + conflicts.push({ + slug: ap.slug, + kind: "agent", + originalName: agent?.name ?? ap.slug, + plannedName: ap.plannedName, + filePath: agent ? ensureMarkdownPath(agent.path) : null, + action: ap.action === "update" ? "update" : "rename", + }); + } + } + + // Projects with collisions + for (const pp of preview.plan.projectPlans) { + if (pp.existingProjectId) { + const project = manifest.projects.find((p) => p.slug === pp.slug); + conflicts.push({ + slug: pp.slug, + kind: "project", + originalName: project?.name ?? pp.slug, + plannedName: pp.plannedName, + filePath: project ? ensureMarkdownPath(project.path) : null, + action: pp.action === "update" ? "update" : "rename", + }); + } + } + + return conflicts; +} + +/** Extract a prefix from the import source URL or local folder name */ +function deriveSourcePrefix(sourceMode: string, importUrl: string, localRootPath: string | null): string | null { + if (sourceMode === "local" && localRootPath) { + return localRootPath.split("/").pop() ?? null; + } + if (sourceMode === "github" || sourceMode === "url") { + const url = importUrl.trim(); + if (!url) return null; + try { + const pathname = new URL(url.startsWith("http") ? url : `https://${url}`).pathname; + // For github URLs like /owner/repo/tree/branch/path - take last segment + const segments = pathname.split("/").filter(Boolean); + return segments.length > 0 ? segments[segments.length - 1] : null; + } catch { + return null; + } + } + return null; +} + +/** Generate a prefix-based rename: e.g. "gstack" + "CEO" → "gstack-CEO" */ +function prefixedName(prefix: string | null, originalName: string): string { + if (!prefix) return originalName; + return `${prefix}-${originalName}`; +} + +// ── Conflict resolution UI ─────────────────────────────────────────── + +function ConflictResolutionList({ + conflicts, + nameOverrides, + skippedSlugs, + onRename, + onToggleSkip, +}: { + conflicts: ConflictItem[]; + nameOverrides: Record; + skippedSlugs: Set; + onRename: (slug: string, newName: string) => void; + onToggleSkip: (slug: string, filePath: string | null) => void; +}) { + if (conflicts.length === 0) return null; + + return ( +
+
+
+

+ Conflicts to resolve +

+ + {conflicts.length} item{conflicts.length === 1 ? "" : "s"} + +
+
+ {conflicts.map((item) => { + const isSkipped = skippedSlugs.has(item.slug); + const currentName = nameOverrides[item.slug] ?? item.plannedName; + const kindLabel = item.kind === "company" ? "COMPANY.md" : item.kind; + return ( +
+ + {kindLabel} + + + + {item.originalName} + + + {item.kind !== "company" && !isSkipped && ( + <> + + onRename(item.slug, e.target.value)} + /> + + )} + + +
+ ); + })} +
+
+
+ ); +} + // ── Helpers ─────────────────────────────────────────────────────────── async function readLocalPackageSelection(fileList: FileList): Promise<{ @@ -253,8 +428,6 @@ export function CompanyImport() { // Target state const [targetMode, setTargetMode] = useState<"existing" | "new">("existing"); - const [collisionStrategy, setCollisionStrategy] = - useState("rename"); const [newCompanyName, setNewCompanyName] = useState(""); // Preview state @@ -264,6 +437,10 @@ export function CompanyImport() { const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [checkedFiles, setCheckedFiles] = useState>(new Set()); + // Conflict resolution state + const [nameOverrides, setNameOverrides] = useState>({}); + const [skippedSlugs, setSkippedSlugs] = useState>(new Set()); + useEffect(() => { setBreadcrumbs([ { label: "Org Chart", href: "/org" }, @@ -282,6 +459,11 @@ export function CompanyImport() { return { type: "url", url }; } + const sourcePrefix = useMemo( + () => deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null), + [sourceMode, importUrl, localPackage], + ); + // Preview mutation const previewMutation = useMutation({ mutationFn: () => { @@ -294,14 +476,39 @@ export function CompanyImport() { targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, - collisionStrategy, + collisionStrategy: "rename", }); }, onSuccess: (result) => { setImportPreview(result); - // Check all files by default + + // Build conflicts and set default name overrides with prefix + const conflicts = buildConflictList(result, targetMode); + const prefix = deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null); + const defaultOverrides: Record = {}; + const defaultSkipped = new Set(); + + for (const c of conflicts) { + if (c.kind === "company") { + // COMPANY.md defaults to skip when importing to existing company + defaultSkipped.add(c.slug); + } else if (c.action === "rename" && prefix) { + // Use prefix-based default rename + defaultOverrides[c.slug] = prefixedName(prefix, c.originalName); + } + } + setNameOverrides(defaultOverrides); + setSkippedSlugs(defaultSkipped); + + // Check all files by default, then uncheck skipped conflict files const allFiles = new Set(Object.keys(result.files)); + for (const c of conflicts) { + if (defaultSkipped.has(c.slug) && c.filePath && allFiles.has(c.filePath)) { + allFiles.delete(c.filePath); + } + } setCheckedFiles(allFiles); + // Expand top-level dirs + all ancestor dirs of files with conflicts (update action) const am = buildActionMap(result); const tree = buildFileTree(result.files, am); @@ -334,6 +541,18 @@ export function CompanyImport() { }, }); + // Build the final nameOverrides to send (only overrides that differ from plannedName) + function buildFinalNameOverrides(): Record | undefined { + if (!importPreview) return undefined; + const overrides: Record = {}; + for (const [slug, name] of Object.entries(nameOverrides)) { + if (name.trim()) { + overrides[slug] = name.trim(); + } + } + return Object.keys(overrides).length > 0 ? overrides : undefined; + } + // Apply mutation const importMutation = useMutation({ mutationFn: () => { @@ -346,7 +565,8 @@ export function CompanyImport() { targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, - collisionStrategy, + collisionStrategy: "rename", + nameOverrides: buildFinalNameOverrides(), }); }, onSuccess: async (result) => { @@ -363,6 +583,8 @@ export function CompanyImport() { setImportPreview(null); setLocalPackage(null); setImportUrl(""); + setNameOverrides({}); + setSkippedSlugs(new Set()); }, onError: (err) => { pushToast({ @@ -399,6 +621,11 @@ export function CompanyImport() { [importPreview, actionMap], ); + const conflicts = useMemo( + () => (importPreview ? buildConflictList(importPreview, targetMode) : []), + [importPreview, targetMode], + ); + const totalFiles = useMemo(() => countFiles(tree), [tree]); const selectedCount = checkedFiles.size; @@ -444,6 +671,37 @@ export function CompanyImport() { }); } + function handleConflictRename(slug: string, newName: string) { + setNameOverrides((prev) => ({ ...prev, [slug]: newName })); + } + + function handleConflictToggleSkip(slug: string, filePath: string | null) { + setSkippedSlugs((prev) => { + const next = new Set(prev); + const wasSkipped = next.has(slug); + if (wasSkipped) { + next.delete(slug); + } else { + next.add(slug); + } + + // Sync with file tree checkboxes + if (filePath) { + setCheckedFiles((prevChecked) => { + const nextChecked = new Set(prevChecked); + if (wasSkipped) { + nextChecked.add(filePath); + } else { + nextChecked.delete(filePath); + } + return nextChecked; + }); + } + + return next; + }); + } + const hasSource = sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0; const hasErrors = importPreview ? importPreview.errors.length > 0 : false; @@ -554,42 +812,21 @@ export function CompanyImport() { )} -
- - - - {targetMode === "existing" && ( - - - - )} -
+ + + {targetMode === "new" && ( {selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected - {importPreview.warnings.length > 0 && ( + {conflicts.length > 0 && ( - {importPreview.warnings.length} warning{importPreview.warnings.length === 1 ? "" : "s"} + {conflicts.length} conflict{conflicts.length === 1 ? "" : "s"} )} {importPreview.errors.length > 0 && ( @@ -655,6 +892,15 @@ export function CompanyImport() { + {/* Conflict resolution list */} + + {/* Warnings */} {importPreview.warnings.length > 0 && (