mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 05:56:19 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Users choose a harness during agent setup. > - Claude, Codex, and OpenCode already receive their qualified Runner default. > - Pi has a qualified native integration but new Pi agents still use the direct adapter. > - This change gives new Pi agents the native default on supported targets. > - Saved agents keep their runner and compatible settings. Legacy remains available in Advanced. ## Linked Issues or Issue Description Refs #17120, #14921, #14922, #14923, #14924, and #14956. Related generic default work: #15422. This PR is the separate Pi follow-up to the merged OpenCode change. **Current behavior** Choosing Pi for a new agent saves the direct Pi adapter. The native Pi integration remains behind the generic experimental flag. **Proposed behavior** Default new Pi agents to ACPX Pi on macOS ARM64/x64 and GNU/Linux x64 with supported glibc. Keep unsupported targets and explicit Legacy selections on the direct adapter. Require an explicit provider/model before test or hire. Permit model-free inactive setup drafts. **Reason and benefit** Users choose Pi once and receive its qualified runner. Saved agents retain their identity and settings. Conversions reject unsupported legacy thinking and launch settings instead of dropping them. ## What Changed - Add Pi to the shared platform/default contract and server creation selection. - Admit native Pi without the generic experimental flag. - Preserve provider models, credentials, workspace settings, exact thinking, and saved native policies. - Add Pi runner controls, branding, setup resume, onboarding, built-in configuration, and import handling. - Add managed Pi OpenRouter routing and matching native provider API-key accounts. Keep subscription and custom gateway exclusions. Clear alternate credentials and reject custom Pi provider overrides. - Keep Pi draft models empty instead of applying Claude's default model. - Verify setup against the pinned Pi runtime. For native Pi on the official OpenRouter route, reverify the API key live without requiring a global Legacy Pi CLI. - Match Pi’s bounded controller close window to its native snapshot cleanup and lifetime verification, preserving explicit caller deadlines and the mandatory drain/suspension proof. - Bind Pi sidecars to the package that owns the pinned native assets, including source and vendored server layouts. Other providers retain their existing package authority. - Keep protocol-owned finish/block tools out of the ACPX forwarded catalog. The trusted bridge installs these tools itself; the complete Rust authorization catalog and terminal receipt ledger remain intact. This fixes actual Pi session startup and applies to other ACPX consumers. - Advance Copilot’s shared-source attestation to v58 because it pins the shared files touched by Pi. Preserve immutable v57 bytes and historical decoding; Pi’s own release identity is unchanged. - Add regression coverage and update the paired maps and developer documentation. ## Verification - Passed: full repository typecheck and full production build on final head656d304783, including the generated attestation repair. - Passed: focused shared/server/UI suites covering default selection, platform fallback, conversion preservation, inactive drafts, managed account compatibility, credential isolation, OpenRouter routing/model IDs, exact thinking controls, and saved-account selection. Relevant batches include 375 server/conversion tests, 267 account/routing tests, and 110 UI setup/account tests. - Passed: 179 attestation, historical-profile, qualified-profile, and native-execution contract tests after CI identified stale Copilot source hashes. The identity generator check passes; the only delta after the passing Pi browser run is this Copilot attestation/history repair. - Passed: 145 managed-account tests, including actual service preparation and API-key adoption/rejection for native Pi without the Legacy CLI; 6 pinned-installation probe tests. - Red then green: an actual task failed session.open because its 54-operation catalog included the bridge-owned terminal tools. The regression with those tools failed before the filter; all 399 Rust library tests pass after it. The full product retry exposed an additional package-authority mismatch. Four authority tests failed before that repair; 11 authority checks and 14 installation/asset checks pass afterward. Direct real Pi session initialization with the corrected package root and catalog succeeds. No model prompt was sent by that diagnostic. - Red then green: real Pi posted Hello from Pi but its controller closed before native cleanup could finish. The cleanup-budget test failed with the old default and passes with the bounded Pi window. All 46 selected suspension, drain, and package-authority tests pass after building their native fixture. - Passed: token gates, paired map checks, Rust formatting, and git diff --check. - Passed real browser acceptance on macOS ARM64, generic native runner flag disabled, sourcea433748a12(Pi code unchanged afterward): the UI selects Paperclip Runner by default, retains Pi and the selected model/account, and verifies the pinned runtime plus live OpenRouter key. A fresh UI-created task, assigned to the UI-created native Pi agent, posted exactly `Hello from Pi` and reached Done without retry or restart. The native run and workspace finalization succeeded; the response and Done persisted after Reload. - Evidence: 8 preserved browser attempts, including the final passing 18-step journey with all screenshots. Prior failures remain recorded. The earlier failed checkpoint run recovered on server restart; that recovery is separate from the fresh final task’s clean success. - Preserved browser failures: missing account control/inherited Claude draft model; incompatible managed-account/model-prefix handling; generic npm probe for Pi; Legacy CLI hello check; duplicate terminal tools and wrong provider-package authority at actual native session.open; a real Hello from Pi comment followed by premature checkpoint cleanup timeout. Each failure has retained screenshots/logs and a corresponding focused repair. The fresh final task proves these repairs end to end. - The exact Runner Vitest shard was also run locally after the attestation fix: the stale-hash failure is gone; 1,833 tests passed, 10 skipped, and two Codex retained-control settlement scenarios failed with `native_cleanup_maintenance_unproven` during terminal ACK/suspension replay. Both scenarios passed in an isolated rerun (2/2), and the same 19-test file passed in the preceding Linux CI run. The precise full-shard timing cause is not established. These Codex scenarios are outside the changed Pi paths; the original local failures are retained; no complete local Runner-shard pass is claimed. The corresponding Runner shard now passes on the final head in hosted Linux CI. All 60 final-head check runs are now terminal and clean (55 successful, 1 neutral, 4 skipped), and the legacy Snyk status is successful. - Local full repository tests were attempted but not completed: provisioning briefly confused a workspace symlink, then a later run was stopped before browser-driven repairs. No full local-suite pass is claimed. Final-head hosted CI is green and fresh Greptile review is 5/5 with all review threads resolved; earlier jobs canceled by runner shutdown are not counted as a pass. ## Risks - Pi cleanup has a finite 45-second default window, accounting for the native 30-second snapshot cleanup and 5-second lifetime fence. Other providers and explicit caller deadlines retain their prior behavior. - Pi requires explicit operator setup with `paperclipai runtime setup pi` on the execution host. This PR does not add automatic downloads. - Linux ARM64, Windows, musl, and unqualified targets retain Legacy. - Legacy thinking levels minimal, medium, and xhigh cannot be translated exactly. Native max thinking, warm lifecycle, or restricted permissions cannot be dropped during a return to Legacy. - Copilot’s bound profile advances to v58 with exact current launch admission; v57 remains decodable for recovery. No historical identity is rewritten. - Existing agents and immutable run descriptors are not migrated. Cloud Pi availability is unchanged. ## Model Used OpenAI GPT-6 through Codex with reasoning, code editing, command execution, and browser use. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2347 lines
99 KiB
TypeScript
2347 lines
99 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
AgentRunnerChoice,
|
|
CompanyPortabilityCollisionStrategy,
|
|
CompanyPortabilityFileEntry,
|
|
CompanyPortabilityImportResult,
|
|
CompanyPortabilityPreviewResult,
|
|
CompanyPortabilitySource,
|
|
CompanyPortabilityAdapterOverride,
|
|
} from "@paperclipai/shared";
|
|
import { agentHarnessType, agentRunner } from "@paperclipai/shared";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import { authApi } from "../api/auth";
|
|
import { ApiError } from "../api/client";
|
|
import { companiesApi, type CompanyImportJobAccepted } from "../api/companies";
|
|
import { adaptersApi } from "../api/adapters";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { agentsApi } from "../api/agents";
|
|
import { routinesApi } from "../api/routines";
|
|
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
|
|
import { MarkdownBody } from "../components/MarkdownBody";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { AgentConfigForm, overlayValuesEqual } from "../components/AgentConfigForm";
|
|
import { asObject, cn } from "../lib/utils";
|
|
import {
|
|
ArrowRight,
|
|
Check,
|
|
ChevronRight,
|
|
Download,
|
|
Loader2,
|
|
Package,
|
|
Upload,
|
|
} from "lucide-react";
|
|
import { GithubIcon } from "../components/icons/github-icon";
|
|
import { Field, adapterLabels } from "../components/agent-config-primitives";
|
|
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
|
import { defaultCreateValues } from "../components/agent-config-defaults";
|
|
import { getUIAdapter, listUIAdapters } from "../adapters";
|
|
import { isPaperclipRunnerProvider, PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES, type CreateConfigValues } from "@paperclipai/adapter-utils";
|
|
import { resolveCodexLocalCliPolicy } from "@paperclipai/adapter-codex-local";
|
|
import {
|
|
type FileTreeNode,
|
|
type FrontmatterData,
|
|
buildFileTree,
|
|
countFiles,
|
|
collectAllPaths,
|
|
parseFrontmatter,
|
|
FRONTMATTER_FIELD_LABELS,
|
|
FileTree,
|
|
} from "../components/FileTree";
|
|
import { readZipArchive } from "../lib/zip";
|
|
import { formatMegabytes } from "../lib/import-preflight";
|
|
import { buildAlreadyImportedMessage, type CompanyImportTransferDeclaration } from "@paperclipai/shared/company-import-transfer";
|
|
import {
|
|
CHUNKED_IMPORT_THRESHOLD_BYTES,
|
|
IMPORT_TRANSFER_PART_ATTEMPTS,
|
|
buildImportTransferManifest,
|
|
} from "../lib/import-transfer";
|
|
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
|
import {
|
|
clearStoredImportJob,
|
|
importJobStorageKey,
|
|
readStoredImportJob,
|
|
waitForNextImportJobPoll,
|
|
writeStoredImportJob,
|
|
} from "../lib/import-job-watch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
// ── Import-specific helpers ───────────────────────────────────────────
|
|
|
|
/** Build a map from file path → planned action (create/update/skip) using the manifest + plan */
|
|
function buildActionMap(preview: CompanyPortabilityPreviewResult): Map<string, string> {
|
|
const map = new Map<string, string>();
|
|
const manifest = preview.manifest;
|
|
|
|
for (const ap of preview.plan.agentPlans) {
|
|
const agent = manifest.agents.find((a) => a.slug === ap.slug);
|
|
if (agent) {
|
|
const path = ensureMarkdownPath(agent.path);
|
|
map.set(path, ap.action);
|
|
}
|
|
}
|
|
|
|
for (const pp of preview.plan.projectPlans) {
|
|
const project = manifest.projects.find((p) => p.slug === pp.slug);
|
|
if (project) {
|
|
const path = ensureMarkdownPath(project.path);
|
|
map.set(path, pp.action);
|
|
}
|
|
}
|
|
|
|
for (const ip of preview.plan.issuePlans) {
|
|
const issue = manifest.issues.find((i) => i.slug === ip.slug);
|
|
if (issue) {
|
|
const path = ensureMarkdownPath(issue.path);
|
|
map.set(path, ip.action);
|
|
}
|
|
}
|
|
|
|
for (const skill of manifest.skills) {
|
|
const path = ensureMarkdownPath(skill.path);
|
|
map.set(path, "create");
|
|
// Also mark skill file inventory
|
|
for (const file of skill.fileInventory) {
|
|
if (preview.files[file.path]) {
|
|
map.set(file.path, "create");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Company file
|
|
if (manifest.company) {
|
|
const path = ensureMarkdownPath(manifest.company.path);
|
|
map.set(path, preview.plan.companyAction === "none" ? "skip" : preview.plan.companyAction);
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function ensureMarkdownPath(p: string): string {
|
|
return p.endsWith(".md") ? p : `${p}.md`;
|
|
}
|
|
|
|
const ACTION_COLORS: Record<string, string> = {
|
|
create: "text-emerald-500 border-emerald-500/30",
|
|
update: "text-amber-500 border-amber-500/30",
|
|
overwrite: "text-red-500 border-red-500/30",
|
|
replace: "text-red-500 border-red-500/30",
|
|
skip: "text-muted-foreground border-border",
|
|
none: "text-muted-foreground border-border",
|
|
};
|
|
|
|
function FrontmatterCard({ data }: { data: FrontmatterData }) {
|
|
return (
|
|
<div className="rounded-md border border-border bg-accent/20 px-4 py-3 mb-4">
|
|
<dl className="grid grid-cols-(--gtc-5) gap-x-4 gap-y-1.5 text-sm">
|
|
{Object.entries(data).map(([key, value]) => (
|
|
<div key={key} className="contents">
|
|
<dt className="text-muted-foreground whitespace-nowrap py-0.5">
|
|
{FRONTMATTER_FIELD_LABELS[key] ?? key}
|
|
</dt>
|
|
<dd className="py-0.5">
|
|
{Array.isArray(value) ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{value.map((item) => (
|
|
<span
|
|
key={item}
|
|
className="inline-flex items-center rounded-md border border-border bg-background px-2 py-0.5 text-xs"
|
|
>
|
|
{item}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span>{value}</span>
|
|
)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Import file tree customization ───────────────────────────────────
|
|
|
|
function renderImportFileExtra(node: FileTreeNode, checked: boolean, renameMap: Map<string, string>) {
|
|
// Show rename indicator only on directories (folders), not individual files
|
|
const renamedTo = node.kind === "dir" ? renameMap.get(node.path) : undefined;
|
|
const actionBadge = node.action ? (
|
|
<Badge variant="outline" className={cn(
|
|
"text-(length:--text-nano) uppercase tracking-wide",
|
|
ACTION_COLORS[node.action] ?? ACTION_COLORS.skip,
|
|
)}>
|
|
{checked ? node.action : "skip"}
|
|
</Badge>
|
|
) : null;
|
|
|
|
if (!actionBadge && !renamedTo) return null;
|
|
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 shrink-0">
|
|
{renamedTo && checked && (
|
|
<span className="text-(length:--text-nano) text-cyan-500 font-mono truncate max-w-(--sz-7rem)" title={renamedTo}>
|
|
→ {renamedTo}
|
|
</span>
|
|
)}
|
|
{actionBadge}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function importFileRowClassName(_node: FileTreeNode, checked: boolean) {
|
|
return !checked ? "opacity-50" : undefined;
|
|
}
|
|
|
|
// ── Preview pane ──────────────────────────────────────────────────────
|
|
|
|
function ImportPreviewPane({
|
|
selectedFile,
|
|
content,
|
|
allFiles,
|
|
action,
|
|
renamedTo,
|
|
}: {
|
|
selectedFile: string | null;
|
|
content: CompanyPortabilityFileEntry | null;
|
|
allFiles: Record<string, CompanyPortabilityFileEntry>;
|
|
action: string | null;
|
|
renamedTo: string | null;
|
|
}) {
|
|
if (!selectedFile || content === null) {
|
|
return (
|
|
<EmptyState icon={Package} message="Select a file to preview its contents." />
|
|
);
|
|
}
|
|
|
|
const textContent = getPortableFileText(content);
|
|
const isMarkdown = selectedFile.endsWith(".md") && textContent !== null;
|
|
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
|
|
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
|
|
const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : "";
|
|
|
|
// Resolve relative image paths within the import package
|
|
const resolveImageSrc = isMarkdown
|
|
? (src: string) => {
|
|
if (/^(?:https?:|data:)/i.test(src)) return null;
|
|
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
|
|
const resolved = dir + src;
|
|
const entry = allFiles[resolved] ?? allFiles[src];
|
|
if (!entry) return null;
|
|
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
|
|
}
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="min-w-0">
|
|
<div className="border-b border-border px-5 py-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0 flex items-center gap-2">
|
|
<span className="truncate font-mono text-sm">{selectedFile}</span>
|
|
{renamedTo && (
|
|
<span className="shrink-0 font-mono text-sm text-cyan-500">
|
|
→ {renamedTo}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{action && (
|
|
<Badge variant="outline" className={cn(
|
|
"uppercase tracking-wide",
|
|
actionColor,
|
|
)}>
|
|
{action}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="min-h-(--sz-560px) px-5 py-5">
|
|
{parsed ? (
|
|
<>
|
|
<FrontmatterCard data={parsed.data} />
|
|
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{parsed.body}</MarkdownBody>}
|
|
</>
|
|
) : isMarkdown ? (
|
|
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{textContent ?? ""}</MarkdownBody>
|
|
) : imageSrc ? (
|
|
<div className="flex min-h-(--sz-520px) items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
|
<img src={imageSrc} alt={selectedFile} className="max-h-(--sz-480px) max-w-full object-contain" />
|
|
</div>
|
|
) : textContent !== null ? (
|
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
|
<code>{textContent}</code>
|
|
</pre>
|
|
) : (
|
|
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
|
|
Binary asset preview is not available for this file type.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Conflict item type ───────────────────────────────────────────────
|
|
|
|
interface ConflictItem {
|
|
slug: string;
|
|
kind: "agent" | "project" | "issue" | "skill";
|
|
originalName: string;
|
|
plannedName: string;
|
|
filePath: string | null;
|
|
action: "rename" | "update";
|
|
}
|
|
|
|
function buildConflictList(
|
|
preview: CompanyPortabilityPreviewResult,
|
|
): ConflictItem[] {
|
|
const conflicts: ConflictItem[] = [];
|
|
const manifest = preview.manifest;
|
|
|
|
// 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 uploaded zip package name */
|
|
function deriveSourcePrefix(
|
|
sourceMode: string,
|
|
importUrl: string,
|
|
localPackageName: string | null,
|
|
localRootPath: string | null,
|
|
): string | null {
|
|
if (sourceMode === "local") {
|
|
if (localRootPath) return localRootPath.split("/").pop() ?? null;
|
|
if (!localPackageName) return null;
|
|
return localPackageName.replace(/\.zip$/i, "") || null;
|
|
}
|
|
if (sourceMode === "github") {
|
|
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}`;
|
|
}
|
|
|
|
async 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) {
|
|
await sidebarPreferencesApi.updateProjectOrder(result.company.id, { orderedIds: orderedProjectIds });
|
|
}
|
|
}
|
|
|
|
// ── Post-import activation ───────────────────────────────────────────
|
|
|
|
interface ActivationItem {
|
|
key: string;
|
|
kind: "agent" | "routine";
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
/** Imported entries that landed paused and can be activated in place; entries without a result id (skipped etc.) are excluded */
|
|
function buildActivationItems(result: CompanyPortabilityImportResult): ActivationItem[] {
|
|
const items: ActivationItem[] = [];
|
|
for (const agent of result.agents) {
|
|
if (!agent.id || agent.action === "skipped") continue;
|
|
items.push({ key: `agent:${agent.id}`, kind: "agent", id: agent.id, name: agent.name });
|
|
}
|
|
for (const routine of result.routines) {
|
|
if (!routine.id) continue;
|
|
items.push({ key: `routine:${routine.id}`, kind: "routine", id: routine.id, name: routine.title });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// ── Conflict resolution UI ───────────────────────────────────────────
|
|
|
|
function ConflictResolutionList({
|
|
conflicts,
|
|
nameOverrides,
|
|
skippedSlugs,
|
|
confirmedSlugs,
|
|
onRename,
|
|
onToggleSkip,
|
|
onToggleConfirm,
|
|
}: {
|
|
conflicts: ConflictItem[];
|
|
nameOverrides: Record<string, string>;
|
|
skippedSlugs: Set<string>;
|
|
confirmedSlugs: Set<string>;
|
|
onRename: (slug: string, newName: string) => void;
|
|
onToggleSkip: (slug: string, filePath: string | null) => void;
|
|
onToggleConfirm: (slug: string) => void;
|
|
}) {
|
|
if (conflicts.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mx-5 mt-3">
|
|
<div className="rounded-md border border-border">
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<h3 className="text-sm font-medium">
|
|
Renames
|
|
</h3>
|
|
<span className="text-xs text-muted-foreground">
|
|
{conflicts.length} item{conflicts.length === 1 ? "" : "s"}
|
|
</span>
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{conflicts.map((item) => {
|
|
const isSkipped = skippedSlugs.has(item.slug);
|
|
const isConfirmed = confirmedSlugs.has(item.slug);
|
|
const currentName = nameOverrides[item.slug] ?? item.plannedName;
|
|
return (
|
|
<div
|
|
key={item.slug}
|
|
className={cn(
|
|
"flex items-center gap-3 px-4 py-2.5 text-sm",
|
|
isSkipped && "opacity-40",
|
|
isConfirmed && !isSkipped && "bg-emerald-500/5",
|
|
)}
|
|
>
|
|
{/* Skip button on the left */}
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"shrink-0 rounded-md border px-2.5 py-1 text-xs transition-colors",
|
|
isSkipped
|
|
? "border-foreground bg-accent text-foreground"
|
|
: "border-border text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
onClick={() => onToggleSkip(item.slug, item.filePath)}
|
|
>
|
|
{isSkipped ? "skipped" : "skip"}
|
|
</button>
|
|
|
|
<Badge variant="outline" className={cn(
|
|
"text-(length:--text-nano) uppercase tracking-wide",
|
|
isSkipped
|
|
? "text-muted-foreground border-border"
|
|
: isConfirmed
|
|
? "text-emerald-500 border-emerald-500/30"
|
|
: "text-amber-500 border-amber-500/30",
|
|
)}>
|
|
{item.kind}
|
|
</Badge>
|
|
|
|
<span className={cn(
|
|
"shrink-0 font-mono text-xs",
|
|
isSkipped ? "text-muted-foreground line-through" : "text-muted-foreground",
|
|
)}>
|
|
{item.originalName}
|
|
</span>
|
|
|
|
{!isSkipped && (
|
|
<>
|
|
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
{isConfirmed ? (
|
|
<span className="min-w-0 flex-1 font-mono text-xs text-emerald-500">
|
|
{currentName}
|
|
</span>
|
|
) : (
|
|
<input
|
|
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 font-mono text-xs outline-none focus:border-foreground"
|
|
value={currentName}
|
|
onChange={(e) => onRename(item.slug, e.target.value)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Confirm rename button on the right */}
|
|
{!isSkipped && (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"ml-auto shrink-0 rounded-md border px-2.5 py-1 text-xs transition-colors inline-flex items-center gap-1.5",
|
|
isConfirmed
|
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-500"
|
|
: "border-border text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
onClick={() => onToggleConfirm(item.slug)}
|
|
>
|
|
{isConfirmed ? (
|
|
<>
|
|
<Check className="h-3 w-3" />
|
|
confirmed
|
|
</>
|
|
) : (
|
|
"confirm rename"
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Adapter type options for import ───────────────────────────────────
|
|
|
|
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [
|
|
...listUIAdapters().map((adapter) => ({
|
|
value: adapter.type,
|
|
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
|
|
})),
|
|
{ value: "openai_dot", label: "OpenAI Dot (experimental)" },
|
|
{ value: "muse", label: "Muse — Personal agent (experimental)" },
|
|
];
|
|
|
|
// Dot is a picker choice backed by the shared Runner API adapter.
|
|
function importAdapterValues(
|
|
choice: string,
|
|
sourceConfig?: Record<string, unknown>,
|
|
sourceAdapterType?: string,
|
|
runner?: AgentRunnerChoice,
|
|
): CreateConfigValues {
|
|
const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(choice);
|
|
const nativeCodex = nativeHarness && choice === "codex_local";
|
|
const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox
|
|
?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox);
|
|
return {
|
|
...defaultCreateValues,
|
|
adapterType: (choice === "openai_dot" || choice === "muse") ? "paperclip_runner" : choice,
|
|
...(runner !== undefined ? { runner } : nativeHarness ? { runner: agentRunner("paperclip_runner") } : {}),
|
|
...(sourceConfig && choice === "codex_local" ? {
|
|
model: typeof sourceConfig.model === "string" ? sourceConfig.model : "",
|
|
thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? ""),
|
|
...(typeof savedBypass === "boolean" ? { codexLegacySandboxBypass: savedBypass } : {}),
|
|
dangerouslyBypassSandbox: typeof savedBypass === "boolean" ? savedBypass
|
|
: nativeCodex
|
|
? (sourceConfig.codexPermissionMode ?? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode) === "never"
|
|
: resolveCodexLocalCliPolicy(sourceConfig).bypass,
|
|
} : {}),
|
|
...(sourceConfig && choice === "opencode_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "" } : {}),
|
|
...(sourceConfig && choice === "pi_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.piThinkingLevel ?? sourceConfig.thinking ?? "") } : {}),
|
|
...(sourceConfig && choice === "claude_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? sourceConfig.effort ?? "") } : {}),
|
|
...(nativeHarness && sourceConfig ? {
|
|
cwd: typeof sourceConfig.cwd === "string" ? sourceConfig.cwd : "",
|
|
instructionsFilePath: typeof sourceConfig.instructionsFilePath === "string" ? sourceConfig.instructionsFilePath : "",
|
|
envBindings: { ...asObject(sourceConfig.env) },
|
|
workspaceStrategyType: String(asObject(sourceConfig.workspaceStrategy).type ?? "project_primary"),
|
|
workspaceBaseRef: String(asObject(sourceConfig.workspaceStrategy).baseRef ?? ""),
|
|
workspaceBranchTemplate: String(asObject(sourceConfig.workspaceStrategy).branchTemplate ?? ""),
|
|
worktreeParentDir: String(asObject(sourceConfig.workspaceStrategy).worktreeParentDir ?? ""),
|
|
runtimeServicesJson: sourceConfig.workspaceRuntime ? JSON.stringify(sourceConfig.workspaceRuntime) : "",
|
|
...(sourceConfig.lifecycleMode === "warm" || sourceConfig.lifecycleMode === "per_turn" ? { paperclipRunnerLifecycleMode: sourceConfig.lifecycleMode } : {}),
|
|
...(typeof sourceConfig.idleTimeoutMs === "number" ? { paperclipRunnerIdleTimeoutMs: sourceConfig.idleTimeoutMs } : {}),
|
|
} : {}),
|
|
...(sourceAdapterType === "paperclip_runner" && choice !== "openai_dot"
|
|
? { adapterSchemaValues: { ...sourceConfig } } : {}),
|
|
...(choice === "openai_dot" ? {
|
|
adapterSchemaValues: {
|
|
provider: "openai_dot",
|
|
allowUnmeteredProvider: sourceConfig?.allowUnmeteredProvider === true,
|
|
dotAttachmentAccess: sourceConfig?.dotAttachmentAccess === true,
|
|
dotWorkspaceAccess: sourceConfig?.dotWorkspaceAccess === true,
|
|
},
|
|
} : choice === "muse" ? { adapterSchemaValues: { provider: "muse", allowUnmeteredProvider: sourceConfig?.allowUnmeteredProvider === true } } : {}),
|
|
};
|
|
}
|
|
|
|
// ── Adapter picker for imported agents ───────────────────────────────
|
|
|
|
interface AdapterPickerItem {
|
|
slug: string;
|
|
name: string;
|
|
/** Adapter type from the package manifest (the source's adapter). */
|
|
adapterType: string;
|
|
/** Display choice, including the separate Dot provider. */
|
|
adapterChoice: string;
|
|
adapterConfig: Record<string, unknown>;
|
|
runner?: AgentRunnerChoice;
|
|
availabilityWarning?: string;
|
|
/**
|
|
* Set when the manifest adapter is not installed on the destination: the
|
|
* adapter type the agent falls back to unless the user picks another one.
|
|
* Null when the manifest adapter is usable here (or availability is unknown,
|
|
* which fails open to the manifest adapter except for experimental Runner providers).
|
|
*/
|
|
fallbackAdapterType: string | null;
|
|
}
|
|
|
|
function AdapterPickerList({
|
|
agents,
|
|
adapterOptions,
|
|
adapterOverrides,
|
|
expandedSlugs,
|
|
configValues,
|
|
onChangeAdapter,
|
|
onToggleExpand,
|
|
onChangeConfig,
|
|
}: {
|
|
agents: AdapterPickerItem[];
|
|
adapterOptions: { value: string; label: string }[];
|
|
adapterOverrides: Record<string, string>;
|
|
expandedSlugs: Set<string>;
|
|
configValues: Record<string, CreateConfigValues>;
|
|
onChangeAdapter: (slug: string, adapterType: string) => void;
|
|
onToggleExpand: (slug: string) => void;
|
|
onChangeConfig: (slug: string, patch: Partial<CreateConfigValues>) => void;
|
|
}) {
|
|
if (agents.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mx-5 mt-3">
|
|
<div className="rounded-md border border-border">
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<h3 className="text-sm font-medium">Adapters</h3>
|
|
<span className="text-xs text-muted-foreground">
|
|
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
|
</span>
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{agents.map((agent) => {
|
|
const selectedType =
|
|
adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterChoice;
|
|
const isExpanded = expandedSlugs.has(agent.slug);
|
|
const vals = configValues[agent.slug] ?? importAdapterValues(
|
|
selectedType, selectedType === agent.adapterChoice ? agent.adapterConfig : undefined,
|
|
selectedType === agent.adapterChoice ? agent.adapterType : undefined,
|
|
selectedType === agent.adapterChoice ? agent.runner : undefined,
|
|
);
|
|
|
|
return (
|
|
<div key={agent.slug}>
|
|
<div className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
|
<Badge variant="outline" className={cn(
|
|
"text-(length:--text-nano) uppercase tracking-wide",
|
|
"text-blue-500 border-blue-500/30",
|
|
)}>
|
|
agent
|
|
</Badge>
|
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
{agent.name}
|
|
</span>
|
|
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
<select
|
|
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs outline-none focus:border-foreground"
|
|
value={selectedType}
|
|
onChange={(e) => onChangeAdapter(agent.slug, e.target.value)}
|
|
>
|
|
{adapterOptions.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
{!adapterOptions.some((option) => option.value === selectedType) && (
|
|
<option value={selectedType}>{agent.adapterChoice === "openai_dot" ? "OpenAI Dot (experimental)" : `${getAdapterLabel(selectedType)} (${agentHarnessType(agent.adapterType, agent.adapterConfig)})`}</option>
|
|
)}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"ml-auto shrink-0 rounded-md border px-2.5 py-1 text-xs transition-colors inline-flex items-center gap-1.5",
|
|
isExpanded
|
|
? "border-foreground bg-accent text-foreground"
|
|
: "border-border text-muted-foreground hover:bg-accent/50",
|
|
)}
|
|
onClick={() => onToggleExpand(agent.slug)}
|
|
>
|
|
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
|
|
configure adapter
|
|
</button>
|
|
</div>
|
|
{agent.fallbackAdapterType && (
|
|
<div className="mx-4 mb-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2">
|
|
<p className="text-xs text-amber-500">
|
|
source adapter {agent.adapterChoice === "openai_dot" ? "OpenAI Dot" : agent.adapterChoice === "muse" ? "Muse — Personal agent" : agent.adapterType} is not installed here — this agent
|
|
will use {adapterLabels[selectedType] ?? getAdapterLabel(selectedType)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{agent.availabilityWarning && selectedType === agent.adapterChoice && (
|
|
<p role="alert" className="mx-4 mb-2.5 text-xs text-destructive">{agent.availabilityWarning}</p>
|
|
)}
|
|
{isExpanded && (
|
|
<div className="border-t border-border bg-accent/10 px-4 py-3 space-y-3">
|
|
<AgentConfigForm
|
|
mode="create"
|
|
values={vals}
|
|
onChange={(patch) => onChangeConfig(agent.slug, patch)}
|
|
showAdapterTypeField={false}
|
|
showAdapterTestEnvironmentButton={false}
|
|
showCreateRunPolicySection={false}
|
|
hideInstructionsFile
|
|
sectionLayout="cards"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
async function readLocalPackageZip(file: File): Promise<{
|
|
name: string;
|
|
/**
|
|
* The raw .zip File itself. Local imports upload this compressed file as
|
|
* multipart instead of inflating it into one large inline JSON body (which
|
|
* truncated in transit on big companies); the parsed `files` map below is
|
|
* kept only for the client-side preflight display and preview affordances.
|
|
*/
|
|
file: File;
|
|
compressedBytes: number;
|
|
rootPath: string | null;
|
|
files: Record<string, CompanyPortabilityFileEntry>;
|
|
}> {
|
|
if (!/\.zip$/i.test(file.name)) {
|
|
throw new Error("Select a .zip organization package.");
|
|
}
|
|
const archive = await readZipArchive(await file.arrayBuffer());
|
|
if (Object.keys(archive.files).length === 0) {
|
|
throw new Error("No package files were found in the selected zip archive.");
|
|
}
|
|
return {
|
|
name: file.name,
|
|
file,
|
|
compressedBytes: file.size,
|
|
rootPath: archive.rootPath,
|
|
files: archive.files,
|
|
};
|
|
}
|
|
|
|
// ── Chunked transfer flow for large local zips ───────────────────────
|
|
//
|
|
// A local .zip over the threshold is not uploaded in one request: one dropped
|
|
// connection would restart the whole multi-minute upload. Instead the file is
|
|
// declared as a chunked transfer (whole-file and per-part sha256), the parts
|
|
// are uploaded individually with per-part retries, and preview/apply run
|
|
// server-side against the assembled spool. Re-declaring the same file — after
|
|
// a failure, a refresh, or between preview and import — resumes the prior
|
|
// transfer, so only the parts the server is missing are ever re-uploaded.
|
|
|
|
function usesChunkedTransfer(file: File): boolean {
|
|
return file.size > CHUNKED_IMPORT_THRESHOLD_BYTES;
|
|
}
|
|
|
|
/** Parts done / bytes uploaded, rendered inside the pending panels while parts upload. */
|
|
interface ImportTransferProgress {
|
|
uploadedParts: number;
|
|
totalParts: number;
|
|
uploadedBytes: number;
|
|
totalBytes: number;
|
|
}
|
|
|
|
function formatTransferProgress(progress: ImportTransferProgress): string {
|
|
const currentPart = Math.min(progress.uploadedParts + 1, progress.totalParts);
|
|
return `Uploading part ${currentPart} of ${progress.totalParts} — ${formatMegabytes(progress.uploadedBytes)} of ${formatMegabytes(progress.totalBytes)} uploaded.`;
|
|
}
|
|
|
|
// ── Async import job flow ─────────────────────────────────────────────
|
|
//
|
|
// Imports run as server-side jobs: the submit returns 202 with a job id and
|
|
// the page polls the status route until the job settles. The connection is
|
|
// no longer load-bearing — a proxy timeout, network blip, or page reload
|
|
// cannot kill the import, and the stored job id lets the page resume
|
|
// watching. Jobs are held in server memory only, so an id the server no
|
|
// longer knows (restart, retention expiry) polls as 404.
|
|
|
|
/** A 409 on submit means this user's previous import is still running; adopt it instead of importing twice. */
|
|
function runningImportJobFromError(err: unknown): CompanyImportJobAccepted | null {
|
|
if (!(err instanceof ApiError) || err.status !== 409) return null;
|
|
const body = err.body as { job?: { id?: unknown }; statusUrl?: unknown } | null;
|
|
const jobId = body?.job?.id;
|
|
if (typeof jobId !== "string" || jobId.length === 0) return null;
|
|
return {
|
|
job: { id: jobId, status: "running" },
|
|
statusUrl: typeof body?.statusUrl === "string" ? body.statusUrl : `/companies/import/jobs/${jobId}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Terminal outcome of watching an import job.
|
|
*
|
|
* - `completed` carries the full import result and drives the activation path.
|
|
* - `completed-expired` is a *server-confirmed* success whose full result we
|
|
* can no longer read: the server reported the job `succeeded` but retained
|
|
* only its compact summary — a cloud tenant job, or a board job whose full
|
|
* in-memory result aged out. The company id lets the page still navigate to
|
|
* the imported company. This is never a failure: the server confirmed the
|
|
* import succeeded before we stopped being able to read the full result.
|
|
*/
|
|
type CompanyImportWatchOutcome =
|
|
| { status: "completed"; result: CompanyPortabilityImportResult }
|
|
| { status: "completed-expired"; companyId: string | null };
|
|
|
|
/** Poll a job to a terminal state; resolves with the outcome or throws the job's error. */
|
|
async function watchImportJob(
|
|
jobId: string,
|
|
storageKey: string,
|
|
): Promise<CompanyImportWatchOutcome> {
|
|
for (;;) {
|
|
let job: Awaited<ReturnType<typeof companiesApi.getImportJob>>["job"] | null = null;
|
|
try {
|
|
job = (await companiesApi.getImportJob(jobId)).job;
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
// The server has no record of this job. The retention sweep only drops
|
|
// jobs that have already *settled* (a running job is never removed), so
|
|
// a 404 while we are still watching means the in-memory record was lost
|
|
// to a restart mid-import — the import never reached a confirmed
|
|
// `succeeded` state and may not have finished. Report that honestly
|
|
// rather than masking a possibly-incomplete import as a success; the
|
|
// refreshed company list lets the user confirm what actually landed.
|
|
clearStoredImportJob(storageKey);
|
|
throw new Error(
|
|
"The server no longer reports this import job — it may have restarted while the import ran.",
|
|
);
|
|
}
|
|
if (
|
|
err instanceof ApiError
|
|
&& err.status >= 400
|
|
&& err.status < 500
|
|
&& err.status !== 429
|
|
) {
|
|
// A permanent client error (an expired board session, lost board
|
|
// access, or a bad request) will never recover by polling again, so
|
|
// stop instead of leaving the import locked in its running state.
|
|
// 429 (rate limited) and 5xx stay transient and fall through below.
|
|
clearStoredImportJob(storageKey);
|
|
throw new Error(
|
|
"The import status can no longer be read — your session may have expired. Reload and sign in to check on it.",
|
|
);
|
|
}
|
|
// Any other poll failure is treated as transient (network blip,
|
|
// dropped connection, rate limit, or a 5xx): the job keeps running
|
|
// server-side, so keep watching rather than reporting a failure that
|
|
// may not exist.
|
|
}
|
|
if (job?.status === "succeeded") {
|
|
clearStoredImportJob(storageKey);
|
|
if (!job.importResult) {
|
|
// The server confirmed success but retained only the compact summary
|
|
// (a cloud tenant job, or a board job whose full result aged out of
|
|
// memory). Still a success — navigate by the summary's company id.
|
|
return {
|
|
status: "completed-expired",
|
|
companyId: job.result?.companyId ?? null,
|
|
};
|
|
}
|
|
return { status: "completed", result: job.importResult };
|
|
}
|
|
if (job?.status === "failed") {
|
|
clearStoredImportJob(storageKey);
|
|
throw new Error(job.error?.message ?? "Import failed on the server.");
|
|
}
|
|
await waitForNextImportJobPoll();
|
|
}
|
|
}
|
|
|
|
// ── Main page ─────────────────────────────────────────────────────────
|
|
|
|
export function CompanyImport() {
|
|
const {
|
|
selectedCompanyId,
|
|
selectedCompany,
|
|
setSelectedCompanyId,
|
|
} = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const { pushToast } = useToastActions();
|
|
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");
|
|
const [importUrl, setImportUrl] = useState("");
|
|
const [localPackage, setLocalPackage] = useState<{
|
|
name: string;
|
|
file: File;
|
|
compressedBytes: number;
|
|
rootPath: string | null;
|
|
files: Record<string, CompanyPortabilityFileEntry>;
|
|
} | null>(null);
|
|
|
|
// Target state
|
|
const [targetMode, setTargetMode] = useState<"existing" | "new">("new");
|
|
const [newCompanyName, setNewCompanyName] = useState("");
|
|
|
|
// Preview state
|
|
const [importPreview, setImportPreview] =
|
|
useState<CompanyPortabilityPreviewResult | 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());
|
|
|
|
// Conflict resolution state
|
|
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
|
|
const [skippedSlugs, setSkippedSlugs] = useState<Set<string>>(new Set());
|
|
const [confirmedSlugs, setConfirmedSlugs] = useState<Set<string>>(new Set());
|
|
const [collisionStrategy, setCollisionStrategy] = useState<CompanyPortabilityCollisionStrategy>("rename");
|
|
|
|
// Adapter override state
|
|
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
|
|
const [adapterExpandedSlugs, setAdapterExpandedSlugs] = useState<Set<string>>(new Set());
|
|
const [adapterConfigValues, setAdapterConfigValues] = useState<Record<string, CreateConfigValues>>({});
|
|
const [adapterConfigEditedSlugs, setAdapterConfigEditedSlugs] = useState<Set<string>>(new Set());
|
|
|
|
// Post-import success / activation state
|
|
const [pauseAutomations, setPauseAutomations] = useState(true);
|
|
const [importOutcome, setImportOutcome] = useState<
|
|
| {
|
|
kind: "full";
|
|
result: CompanyPortabilityImportResult;
|
|
dashboardPath: string;
|
|
pausedAutomations: boolean;
|
|
}
|
|
| {
|
|
kind: "expired";
|
|
companyName: string | null;
|
|
dashboardPath: string | null;
|
|
pausedAutomations: boolean;
|
|
}
|
|
| null
|
|
>(null);
|
|
const [activationChecked, setActivationChecked] = useState<Set<string>>(new Set());
|
|
const [activatedKeys, setActivatedKeys] = useState<Set<string>>(new Set());
|
|
const [activationFailures, setActivationFailures] = useState<Record<string, string>>({});
|
|
const [isActivating, setIsActivating] = useState(false);
|
|
|
|
// A still-running job from a previous page load being re-attached to. While
|
|
// set, the page shows a "resume watching" panel instead of the stale form.
|
|
const [resumedWatchJobId, setResumedWatchJobId] = useState<string | null>(null);
|
|
const resumeAttemptedRef = useRef(false);
|
|
|
|
// Chunked transfer state. The manifest cache is keyed by File identity so
|
|
// preview and import hash the (large) package once; progress is set only
|
|
// while parts are actually uploading, so the pending panels can report it.
|
|
const transferManifestRef = useRef<{ file: File; manifest: CompanyImportTransferDeclaration } | null>(null);
|
|
const [transferProgress, setTransferProgress] = useState<ImportTransferProgress | null>(null);
|
|
|
|
async function ensureTransferManifest(file: File): Promise<CompanyImportTransferDeclaration> {
|
|
if (transferManifestRef.current?.file === file) {
|
|
return transferManifestRef.current.manifest;
|
|
}
|
|
// The whole file is read once here for hashing; parts are later uploaded
|
|
// as Blob slices of the File, so this buffer is not retained past hashing.
|
|
const manifest = await buildImportTransferManifest(await file.arrayBuffer());
|
|
transferManifestRef.current = { file, manifest };
|
|
return manifest;
|
|
}
|
|
|
|
/**
|
|
* Declare (or resume) the transfer for this file and upload every part the
|
|
* server reports missing, sequentially with per-part retries. Resolves with
|
|
* the transfer id once the server holds every part.
|
|
*/
|
|
async function uploadImportTransfer(file: File): Promise<string> {
|
|
const manifest = await ensureTransferManifest(file);
|
|
const created = await companiesApi.importTransferCreate(manifest);
|
|
if (created.alreadyCompleted) {
|
|
// The server keys transfers by content, and this exact zip already
|
|
// finished an apply — its parts are gone, so it cannot be re-run. Name
|
|
// the company that apply created so this reads as "your import exists
|
|
// over there", not as data loss.
|
|
throw new Error(buildAlreadyImportedMessage(created.company));
|
|
}
|
|
const missing = new Set(created.missingParts);
|
|
let uploadedParts = manifest.parts.length - missing.size;
|
|
let uploadedBytes = manifest.parts.reduce(
|
|
(sum, part) => (missing.has(part.index) ? sum : sum + part.byteSize),
|
|
0,
|
|
);
|
|
try {
|
|
setTransferProgress({
|
|
uploadedParts,
|
|
totalParts: manifest.parts.length,
|
|
uploadedBytes,
|
|
totalBytes: manifest.totalBytes,
|
|
});
|
|
for (const part of manifest.parts) {
|
|
if (!missing.has(part.index)) continue;
|
|
const offset = part.index * manifest.partSizeBytes;
|
|
const bytes = file.slice(offset, offset + part.byteSize);
|
|
let lastError: unknown = null;
|
|
let uploaded = false;
|
|
for (let attempt = 0; attempt < IMPORT_TRANSFER_PART_ATTEMPTS && !uploaded; attempt += 1) {
|
|
try {
|
|
await companiesApi.importTransferUploadPart(created.transferId, part.index, bytes);
|
|
uploaded = true;
|
|
} catch (err) {
|
|
lastError = err;
|
|
}
|
|
}
|
|
if (!uploaded) {
|
|
// The parts already uploaded stay spooled server-side; retrying the
|
|
// preview/import resumes from them instead of starting over.
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(`Part ${part.index + 1} of ${manifest.parts.length} failed to upload.`);
|
|
}
|
|
uploadedParts += 1;
|
|
uploadedBytes += part.byteSize;
|
|
setTransferProgress({
|
|
uploadedParts,
|
|
totalParts: manifest.parts.length,
|
|
uploadedBytes,
|
|
totalBytes: manifest.totalBytes,
|
|
});
|
|
}
|
|
} finally {
|
|
setTransferProgress(null);
|
|
}
|
|
return created.transferId;
|
|
}
|
|
|
|
// Fetch current company agents to find CEO adapter type
|
|
const { data: companyAgents } = useQuery({
|
|
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none"],
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: Boolean(selectedCompanyId),
|
|
});
|
|
const ceoAdapterType = useMemo(() => {
|
|
if (!companyAgents) return "claude_local";
|
|
const ceo = companyAgents.find((a) => a.role === "ceo");
|
|
return ceo?.adapterType ?? "claude_local";
|
|
}, [companyAgents]);
|
|
|
|
const { data: installedAdapters } = useQuery({
|
|
queryKey: queryKeys.adapters.all,
|
|
queryFn: () => adaptersApi.list(),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
// Null while the list is loading or unreadable: availability is unknown, so
|
|
// fail open and trust the manifest rather than coercing every agent.
|
|
const availableAdapterTypes = useMemo(() => {
|
|
if (!installedAdapters) return null;
|
|
return new Set(installedAdapters.filter((a) => !a.disabled).map((a) => a.type));
|
|
}, [installedAdapters]);
|
|
const { data: experimentalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
retry: false,
|
|
});
|
|
const runnerInstalled = availableAdapterTypes?.has("paperclip_runner") === true;
|
|
const nativeRunnerAvailable = runnerInstalled && experimentalSettings?.enableNativeRunner === true;
|
|
const dotAvailable = runnerInstalled && experimentalSettings?.enableOpenAiDot === true;
|
|
const museAvailable = nativeRunnerAvailable && experimentalSettings?.enableMuse === true;
|
|
const importAdapterOptions = useMemo(
|
|
() => IMPORT_ADAPTER_OPTIONS.filter((option) =>
|
|
option.value === "paperclip_runner" ? nativeRunnerAvailable
|
|
: option.value === "openai_dot" ? dotAvailable : option.value === "muse" ? museAvailable : true,
|
|
),
|
|
[nativeRunnerAvailable, dotAvailable, museAvailable],
|
|
);
|
|
|
|
const localZipHelpText =
|
|
"Upload a .zip exported directly from Paperclip. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly.";
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
|
{ label: "Settings", href: "/company/settings" },
|
|
{ label: "Import" },
|
|
]);
|
|
}, [selectedCompany?.name, setBreadcrumbs]);
|
|
|
|
// The GitHub/URL source still travels inline (it is just a URL, so it never
|
|
// hits the inline-size ceiling). The local .zip source uploads its raw
|
|
// compressed file as multipart instead — see the preview/import mutations.
|
|
function buildGithubSource(): CompanyPortabilitySource | null {
|
|
const url = importUrl.trim();
|
|
if (!url) return null;
|
|
return { type: "github", url };
|
|
}
|
|
|
|
// Import fields shared by preview and apply, and by both transports. The
|
|
// multipart zip upload ships these as a JSON `meta` field; the inline GitHub
|
|
// request spreads them alongside its `source`.
|
|
function buildImportMetaCommon() {
|
|
return {
|
|
include: { company: true, agents: true, projects: true, issues: true },
|
|
target:
|
|
targetMode === "new"
|
|
? { mode: "new_company" as const, newCompanyName: newCompanyName || null }
|
|
: { mode: "existing_company" as const, companyId: selectedCompanyId! },
|
|
collisionStrategy,
|
|
};
|
|
}
|
|
|
|
// Monotonic id for preview requests. Structural configuration changes bump
|
|
// it, so an in-flight preview they supersede settles silently instead of
|
|
// publishing a result or error for a package that is no longer selected.
|
|
// Imports are not gated this way: they mutate the server, so their outcome
|
|
// is always published.
|
|
const previewGenerationRef = useRef(0);
|
|
|
|
// Preview mutation
|
|
const previewMutation = useMutation({
|
|
mutationFn: async (_generation: number) => {
|
|
const meta = buildImportMetaCommon();
|
|
if (sourceMode === "local") {
|
|
if (!localPackage) throw new Error("No source configured.");
|
|
if (usesChunkedTransfer(localPackage.file)) {
|
|
// Too large for one request: upload (or resume) the chunked
|
|
// transfer, then preview against the server-side assembled spool.
|
|
const transferId = await uploadImportTransfer(localPackage.file);
|
|
return companiesApi.importTransferPreview(transferId, meta);
|
|
}
|
|
// Upload the raw compressed zip; the server unzips it into the same
|
|
// inline bundle the importer consumes.
|
|
return companiesApi.importPreviewPackage(localPackage.file, meta);
|
|
}
|
|
const source = buildGithubSource();
|
|
if (!source) throw new Error("No source configured.");
|
|
return companiesApi.importPreview({ source, ...meta });
|
|
},
|
|
onSuccess: (result, generation) => {
|
|
if (generation !== previewGenerationRef.current) return;
|
|
setImportPreview(result);
|
|
|
|
// Build conflicts and set default name overrides with prefix
|
|
const conflicts = buildConflictList(result);
|
|
const prefix = deriveSourcePrefix(
|
|
sourceMode,
|
|
importUrl,
|
|
localPackage?.name ?? null,
|
|
localPackage?.rootPath ?? null,
|
|
);
|
|
const defaultOverrides: Record<string, string> = {};
|
|
|
|
for (const c of conflicts) {
|
|
if (c.action === "rename" && prefix) {
|
|
// Use prefix-based default rename
|
|
defaultOverrides[c.slug] = prefixedName(prefix, c.originalName);
|
|
}
|
|
}
|
|
setNameOverrides(defaultOverrides);
|
|
setSkippedSlugs(new Set());
|
|
setConfirmedSlugs(new Set());
|
|
|
|
// Adapter overrides start empty: each agent keeps its manifest adapter
|
|
// unless the user changes it, or the manifest adapter is not installed
|
|
// here (handled per-agent via a warned fallback, never seeded silently).
|
|
setAdapterOverrides({});
|
|
setAdapterExpandedSlugs(new Set());
|
|
setAdapterConfigValues({});
|
|
setAdapterConfigEditedSlugs(new Set());
|
|
|
|
// Check all files by default, then uncheck COMPANY.md for existing company
|
|
const allFiles = new Set(Object.keys(result.files));
|
|
if (targetMode === "existing" && result.manifest.company && result.plan.companyAction === "update") {
|
|
const companyPath = ensureMarkdownPath(result.manifest.company.path);
|
|
allFiles.delete(companyPath);
|
|
}
|
|
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);
|
|
const dirsToExpand = new Set<string>();
|
|
for (const node of tree) {
|
|
if (node.kind === "dir") dirsToExpand.add(node.path);
|
|
}
|
|
// Auto-expand directories containing conflicting files so they're visible
|
|
for (const [filePath, action] of am) {
|
|
if (action === "update") {
|
|
const segments = filePath.split("/").filter(Boolean);
|
|
let current = "";
|
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
current = current ? `${current}/${segments[i]}` : segments[i];
|
|
dirsToExpand.add(current);
|
|
}
|
|
}
|
|
}
|
|
setExpandedDirs(dirsToExpand);
|
|
// Select first file
|
|
const firstFile = Object.keys(result.files)[0];
|
|
if (firstFile) setSelectedFile(firstFile);
|
|
},
|
|
onError: (err, generation) => {
|
|
if (generation !== previewGenerationRef.current) return;
|
|
pushToast({
|
|
tone: "error",
|
|
title: "Preview failed",
|
|
body: err instanceof Error ? err.message : "Failed to preview import.",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Build the final nameOverrides to send (only overrides that differ from plannedName)
|
|
function buildFinalNameOverrides(): Record<string, string> | undefined {
|
|
if (!importPreview) return undefined;
|
|
const overrides: Record<string, string> = {};
|
|
for (const [slug, name] of Object.entries(nameOverrides)) {
|
|
if (name.trim()) {
|
|
overrides[slug] = name.trim();
|
|
}
|
|
}
|
|
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
|
}
|
|
|
|
function buildSelectedFiles(): string[] | undefined {
|
|
const selected = Array.from(checkedFiles).sort();
|
|
return selected.length > 0 ? selected : undefined;
|
|
}
|
|
|
|
/** Storage key for the pending submission, scoped per company + package. */
|
|
function currentImportJobStorageKey(): string {
|
|
const packageName =
|
|
sourceMode === "local"
|
|
? localPackage?.name ?? "package"
|
|
: importUrl.trim() || "package";
|
|
return importJobStorageKey(selectedCompanyId ?? "unknown-company", packageName);
|
|
}
|
|
|
|
// Apply mutation. The preview the import was started from and the
|
|
// submitted pause option ride along as the mutation variables so the
|
|
// request and its callbacks never read state that a later edit replaced.
|
|
// The import runs as a server-side job: the mutation stays pending across
|
|
// the 202 submit and every poll, so the existing progress panel, error
|
|
// panel, and structural locks cover the whole job, not just one request.
|
|
const importMutation = useMutation({
|
|
mutationFn: async (variables: {
|
|
previewForImport: CompanyPortabilityPreviewResult | null;
|
|
pauseAutomations: boolean;
|
|
/** Re-attach to a job stored by a previous page load instead of submitting. */
|
|
resume?: { jobId: string; storageKey: string };
|
|
}) => {
|
|
if (variables.resume) {
|
|
return watchImportJob(variables.resume.jobId, variables.resume.storageKey);
|
|
}
|
|
const meta = {
|
|
...buildImportMetaCommon(),
|
|
nameOverrides: buildFinalNameOverrides(),
|
|
selectedFiles: buildSelectedFiles(),
|
|
adapterOverrides: buildFinalAdapterOverrides(),
|
|
pauseAutomations: variables.pauseAutomations,
|
|
};
|
|
const localFile = sourceMode === "local" ? localPackage?.file : null;
|
|
const githubSource = sourceMode === "local" ? null : buildGithubSource();
|
|
if (sourceMode === "local" ? !localFile : !githubSource) {
|
|
throw new Error("No source configured.");
|
|
}
|
|
const storageKey = currentImportJobStorageKey();
|
|
let accepted: CompanyImportJobAccepted;
|
|
try {
|
|
if (localFile && usesChunkedTransfer(localFile)) {
|
|
// Same transfer the preview uploaded: re-declaring resumes it, so
|
|
// normally no parts travel again and this goes straight to apply.
|
|
const transferId = await uploadImportTransfer(localFile);
|
|
accepted = await companiesApi.importTransferApply(transferId, meta);
|
|
} else {
|
|
accepted = localFile
|
|
? await companiesApi.importBundlePackageAsync(localFile, meta)
|
|
: await companiesApi.importBundleAsync({ source: githubSource!, ...meta });
|
|
}
|
|
} catch (err) {
|
|
// 409: this user's previous import is still running. Adopt that job
|
|
// and watch it — never fire a second import.
|
|
const running = runningImportJobFromError(err);
|
|
if (!running) throw err;
|
|
accepted = running;
|
|
}
|
|
writeStoredImportJob(storageKey, {
|
|
jobId: accepted.job.id,
|
|
pauseAutomations: variables.pauseAutomations,
|
|
});
|
|
return watchImportJob(accepted.job.id, storageKey);
|
|
},
|
|
onSuccess: async (outcome, { previewForImport, pauseAutomations: submittedPauseAutomations }) => {
|
|
setResumedWatchJobId(null);
|
|
// The company list powers the switcher; refresh it on every success path
|
|
// so the imported company appears immediately without a manual reload.
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
|
|
if (outcome.status === "completed-expired") {
|
|
// The import finished and wrote all its data, but the job's result
|
|
// expired (or was never retained) before we could read it. This is a
|
|
// success, not a failure: keep the landed company's identity so the
|
|
// outcome screen can take the user straight there instead of leaving
|
|
// them to hunt through the switcher.
|
|
let expiredCompanyName: string | null = null;
|
|
let expiredDashboardPath: string | null = null;
|
|
if (outcome.companyId) {
|
|
try {
|
|
const importedCompany = await companiesApi.get(outcome.companyId);
|
|
setSelectedCompanyId(importedCompany.id);
|
|
expiredCompanyName = importedCompany.name;
|
|
expiredDashboardPath = `/${importedCompany.issuePrefix}/dashboard`;
|
|
} catch {
|
|
// The company id may be unreadable (permissions, race); the
|
|
// refreshed company list still surfaces the import.
|
|
}
|
|
}
|
|
setImportOutcome({
|
|
kind: "expired",
|
|
companyName: expiredCompanyName,
|
|
dashboardPath: expiredDashboardPath,
|
|
pausedAutomations: submittedPauseAutomations,
|
|
});
|
|
pushToast({
|
|
tone: "success",
|
|
title: "Import completed",
|
|
body: "Open the organization to view it.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const result = outcome.result;
|
|
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;
|
|
await applyImportedSidebarOrder(previewForImport, result, sidebarOrderUserId);
|
|
setSelectedCompanyId(importedCompany.id);
|
|
setActivationChecked(new Set(buildActivationItems(result).map((item) => item.key)));
|
|
setActivatedKeys(new Set());
|
|
setActivationFailures({});
|
|
setImportOutcome({
|
|
kind: "full",
|
|
result,
|
|
dashboardPath: `/${importedCompany.issuePrefix}/dashboard`,
|
|
pausedAutomations: submittedPauseAutomations,
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
setResumedWatchJobId(null);
|
|
pushToast({
|
|
tone: "error",
|
|
title: "Import failed",
|
|
body: err instanceof Error ? err.message : "Failed to apply import.",
|
|
});
|
|
},
|
|
});
|
|
|
|
// On mount, re-attach to a job stored by a previous page load. This is what
|
|
// makes dropped connections and reloads harmless: the job kept running
|
|
// server-side, so resume watching it instead of showing the stale form.
|
|
const resumeImportWatch = importMutation.mutate;
|
|
useEffect(() => {
|
|
if (resumeAttemptedRef.current || !selectedCompanyId) return;
|
|
resumeAttemptedRef.current = true;
|
|
const stored = readStoredImportJob(selectedCompanyId);
|
|
if (!stored) return;
|
|
setResumedWatchJobId(stored.jobId);
|
|
resumeImportWatch({
|
|
previewForImport: null,
|
|
pauseAutomations: stored.pauseAutomations,
|
|
resume: { jobId: stored.jobId, storageKey: stored.storageKey },
|
|
});
|
|
}, [selectedCompanyId, resumeImportWatch]);
|
|
|
|
// Any change to the import configuration supersedes the request a settled
|
|
// progress/error panel describes, so it clears settled mutation state. A
|
|
// pending request is never detached: its panel keeps reporting it (and the
|
|
// action buttons stay disabled) until it settles. Payload edits made
|
|
// against a rendered preview (new-company name, pause toggle, file
|
|
// selection, conflict choices, adapter overrides, dropping attachments)
|
|
// reset only the panels; structural changes also discard the preview
|
|
// itself. Structural controls are locked while an import runs, so this can
|
|
// never unmount the preview section that hosts a running import's status
|
|
// panels.
|
|
function resetMutationState() {
|
|
if (!previewMutation.isPending) previewMutation.reset();
|
|
if (!importMutation.isPending) importMutation.reset();
|
|
}
|
|
|
|
function resetImportFlowState() {
|
|
previewGenerationRef.current += 1;
|
|
setImportPreview(null);
|
|
resetMutationState();
|
|
}
|
|
|
|
async function handleChooseLocalPackage(e: ChangeEvent<HTMLInputElement>) {
|
|
const fileList = e.target.files;
|
|
if (!fileList || fileList.length === 0) return;
|
|
try {
|
|
const pkg = await readLocalPackageZip(fileList[0]!);
|
|
setLocalPackage(pkg);
|
|
resetImportFlowState();
|
|
} catch (err) {
|
|
pushToast({
|
|
tone: "error",
|
|
title: "Package read failed",
|
|
body: err instanceof Error ? err.message : "Failed to read folder.",
|
|
});
|
|
}
|
|
}
|
|
|
|
const actionMap = useMemo(
|
|
() => (importPreview ? buildActionMap(importPreview) : new Map<string, string>()),
|
|
[importPreview],
|
|
);
|
|
|
|
const tree = useMemo(
|
|
() => (importPreview ? buildFileTree(importPreview.files, actionMap) : []),
|
|
[importPreview, actionMap],
|
|
);
|
|
|
|
const conflicts = useMemo(
|
|
() => (importPreview ? buildConflictList(importPreview) : []),
|
|
[importPreview],
|
|
);
|
|
|
|
// Map directory paths → planned rename name for display in the file tree
|
|
// Also maps file paths for use in the preview header
|
|
const renameMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
if (!importPreview) return map;
|
|
for (const c of conflicts) {
|
|
if (!c.filePath) continue;
|
|
const isSkipped = skippedSlugs.has(c.slug);
|
|
if (isSkipped) continue;
|
|
const renamedTo = nameOverrides[c.slug] ?? c.plannedName;
|
|
if (renamedTo === c.originalName) continue;
|
|
// Map the parent directory (e.g. agents/ceo → gstack-ceo) for the file tree
|
|
const parentDir = c.filePath.split("/").slice(0, -1).join("/");
|
|
if (parentDir) map.set(parentDir, renamedTo);
|
|
// Map the file path too — used by the preview header, not shown in tree
|
|
map.set(c.filePath, renamedTo);
|
|
}
|
|
return map;
|
|
}, [importPreview, conflicts, nameOverrides, skippedSlugs]);
|
|
|
|
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 (!importPreview) return;
|
|
resetMutationState();
|
|
setCheckedFiles((prev) => {
|
|
const next = new Set(prev);
|
|
if (kind === "file") {
|
|
if (next.has(path)) next.delete(path);
|
|
else next.add(path);
|
|
} else {
|
|
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(tree, path);
|
|
if (dirNode) {
|
|
const childFiles = collectAllPaths(dirNode.children, "file");
|
|
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 handleConflictRename(slug: string, newName: string) {
|
|
resetMutationState();
|
|
setNameOverrides((prev) => ({ ...prev, [slug]: newName }));
|
|
// Editing the name un-confirms
|
|
setConfirmedSlugs((prev) => {
|
|
if (!prev.has(slug)) return prev;
|
|
const next = new Set(prev);
|
|
next.delete(slug);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleConflictToggleConfirm(slug: string) {
|
|
resetMutationState();
|
|
setConfirmedSlugs((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(slug)) next.delete(slug);
|
|
else next.add(slug);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleConflictToggleSkip(slug: string, filePath: string | null) {
|
|
resetMutationState();
|
|
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;
|
|
});
|
|
}
|
|
|
|
function handleAdapterChange(slug: string, adapterType: string) {
|
|
resetMutationState();
|
|
setAdapterOverrides((prev) => ({ ...prev, [slug]: adapterType }));
|
|
setAdapterConfigEditedSlugs(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(slug);
|
|
return next;
|
|
});
|
|
// Reset config values when adapter type changes
|
|
setAdapterConfigValues((prev) => {
|
|
const next = { ...prev };
|
|
if (adapterType === "openai_dot" || adapterType === "muse" || adapterType === "paperclip_runner") {
|
|
next[slug] = importAdapterValues(adapterType);
|
|
} else {
|
|
delete next[slug];
|
|
}
|
|
return next;
|
|
});
|
|
if (adapterType === "openai_dot" || adapterType === "muse") {
|
|
setAdapterExpandedSlugs((prev) => new Set([...prev, slug]));
|
|
}
|
|
}
|
|
|
|
function handleAdapterToggleExpand(slug: string) {
|
|
setAdapterExpandedSlugs((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(slug)) next.delete(slug);
|
|
else next.add(slug);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleAdapterConfigChange(slug: string, patch: Partial<CreateConfigValues>) {
|
|
resetMutationState();
|
|
const agent = adapterAgents.find((a) => a.slug === slug);
|
|
const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local";
|
|
if (Object.keys(patch).some(key => key !== "runner" && key !== "resolvedRunner")) {
|
|
setAdapterConfigEditedSlugs(prev => new Set([...prev, slug]));
|
|
}
|
|
setAdapterConfigValues((prev) => ({
|
|
...prev,
|
|
[slug]: { ...(prev[slug] ?? importAdapterValues(
|
|
currentType, currentType === agent?.adapterChoice ? agent.adapterConfig : undefined,
|
|
currentType === agent?.adapterChoice ? agent.adapterType : undefined,
|
|
currentType === agent?.adapterChoice ? agent.runner : undefined,
|
|
)), ...patch },
|
|
}));
|
|
}
|
|
|
|
function handleToggleActivationItem(key: string) {
|
|
setActivationChecked((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function handleActivateSelected() {
|
|
if (!importOutcome || importOutcome.kind !== "full" || isActivating) return;
|
|
setIsActivating(true);
|
|
const nextActivated = new Set(activatedKeys);
|
|
const nextFailures: Record<string, string> = {};
|
|
for (const item of buildActivationItems(importOutcome.result)) {
|
|
if (!activationChecked.has(item.key) || nextActivated.has(item.key)) continue;
|
|
try {
|
|
if (item.kind === "agent") {
|
|
await agentsApi.resume(item.id);
|
|
} else {
|
|
await routinesApi.update(item.id, { status: "active" });
|
|
}
|
|
nextActivated.add(item.key);
|
|
} catch (err) {
|
|
nextFailures[item.key] = err instanceof Error ? err.message : "Activation failed.";
|
|
}
|
|
}
|
|
setActivatedKeys(nextActivated);
|
|
setActivationFailures(nextFailures);
|
|
setIsActivating(false);
|
|
const failureCount = Object.keys(nextFailures).length;
|
|
if (failureCount > 0) {
|
|
pushToast({
|
|
tone: "error",
|
|
title: "Some items were not activated",
|
|
body: `${failureCount} item${failureCount === 1 ? "" : "s"} failed to activate; the rest were activated.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const adapterAgents = useMemo<AdapterPickerItem[]>(() => {
|
|
if (!importPreview) return [];
|
|
return importPreview.manifest.agents.map((a) => {
|
|
const nativeCodex = a.adapterType === "paperclip_runner"
|
|
&& (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined);
|
|
const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner"
|
|
&& ["claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(a.adapterType, a.adapterConfig)));
|
|
const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot"
|
|
? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeHarness ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
|
|
let fallbackAdapterType: string | null = null;
|
|
let availabilityWarning: string | undefined;
|
|
if (a.adapterType === "paperclip_runner") {
|
|
const available = nativeHarness ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : adapterChoice === "muse" ? museAvailable : nativeRunnerAvailable;
|
|
if (!available && (availableAdapterTypes || !nativeHarness)) {
|
|
availabilityWarning = nativeHarness
|
|
? "Paperclip Runner is unavailable here. Choose a runner or another harness explicitly."
|
|
: `Paperclip Runner provider ${String(a.adapterConfig?.provider)} requires its experimental feature to be enabled. This import keeps its saved harness until you choose another one.`;
|
|
}
|
|
} else if (availableAdapterTypes && !availableAdapterTypes.has(a.adapterType)) {
|
|
// The fallback must itself be installed: the CEO's adapter when it is,
|
|
// else any installed adapter, else null so the manifest adapter stands
|
|
// and the server's unknown-adapter rejection is the backstop.
|
|
const selectableTypes = [...availableAdapterTypes].filter((type) =>
|
|
type !== "paperclip_runner" || nativeRunnerAvailable,
|
|
);
|
|
fallbackAdapterType = selectableTypes.includes(ceoAdapterType)
|
|
? ceoAdapterType
|
|
: selectableTypes[0] ?? null;
|
|
}
|
|
|
|
return {
|
|
slug: a.slug,
|
|
name: a.name,
|
|
adapterType: a.adapterType,
|
|
adapterChoice,
|
|
adapterConfig: a.adapterConfig ?? {},
|
|
runner: a.runner,
|
|
availabilityWarning,
|
|
fallbackAdapterType,
|
|
};
|
|
});
|
|
}, [importPreview, availableAdapterTypes, ceoAdapterType, runnerInstalled, nativeRunnerAvailable, dotAvailable, museAvailable]);
|
|
|
|
/** The adapter type an imported agent will actually use: an explicit user pick, else the availability fallback, else the manifest adapter. */
|
|
function effectiveAdapterType(agent: AdapterPickerItem): string {
|
|
return adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterChoice;
|
|
}
|
|
|
|
// Build final adapterOverrides for import request. Only agents that diverge
|
|
// from the manifest adapter (a user pick or an availability fallback) or
|
|
// carry edited adapter config send an override — untouched agents flow
|
|
// through with none, so the manifest adapter survives the import.
|
|
function buildFinalAdapterOverrides(): Record<string, CompanyPortabilityAdapterOverride> | undefined {
|
|
const overrides: Record<string, CompanyPortabilityAdapterOverride> = {};
|
|
for (const agent of adapterAgents) {
|
|
const selectedType = effectiveAdapterType(agent);
|
|
const configVals = adapterConfigValues[agent.slug];
|
|
if (selectedType === agent.adapterChoice && !adapterConfigEditedSlugs.has(agent.slug)
|
|
&& (!configVals || configVals.runner === importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, agent.runner).runner)) continue;
|
|
const retainsNativeHarness = agent.adapterType === "paperclip_runner"
|
|
&& selectedType === agent.adapterChoice && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedType);
|
|
const apiType = selectedType === "openai_dot" || selectedType === "muse" || retainsNativeHarness ? "paperclip_runner" : selectedType;
|
|
const override: CompanyPortabilityAdapterOverride = { adapterType: apiType };
|
|
if (configVals?.runner !== undefined) override.runner = configVals.runner;
|
|
if (configVals && (selectedType !== agent.adapterChoice || adapterConfigEditedSlugs.has(agent.slug))) {
|
|
const provider = configVals.adapterSchemaValues?.provider;
|
|
if (configVals.adapterType === "paperclip_runner" && provider !== undefined && !isPaperclipRunnerProvider(provider)) {
|
|
throw new Error(`Unknown Paperclip Runner provider ${String(provider)}. Keep the package configuration or explicitly choose another harness.`);
|
|
}
|
|
if (selectedType === "openai_dot" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) {
|
|
throw new Error("OpenAI Dot requires acknowledgement of external billing. Configure the Dot adapter and enable Allow externally billed provider.");
|
|
}
|
|
if (selectedType === "muse" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) {
|
|
throw new Error("Muse requires acknowledgement of external billing. Configure Muse and enable Allow externally billed provider.");
|
|
}
|
|
const nativeClaudeForm = selectedType === "claude_local" && (retainsNativeHarness
|
|
|| (configVals.runner !== "legacy" && configVals.adapterSchemaValues?.provider === "acpx"
|
|
&& configVals.adapterSchemaValues.acpxAgent === "claude"));
|
|
const build = getUIAdapter(nativeClaudeForm ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig;
|
|
const edited = build(configVals);
|
|
if (agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice
|
|
&& ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(selectedType) && configVals.adapterType === selectedType
|
|
&& agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === selectedType) {
|
|
const original = importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, configVals.runner);
|
|
const baseline = build(original);
|
|
const retained = { ...agent.adapterConfig };
|
|
const applyChanges = (before: Record<string, unknown>, after: Record<string, unknown>) => {
|
|
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
if (overlayValuesEqual(before[key], after[key])) continue;
|
|
if (after[key] === undefined) delete retained[key];
|
|
else retained[key] = after[key];
|
|
}
|
|
};
|
|
applyChanges(original.adapterSchemaValues ?? {}, configVals.adapterSchemaValues ?? {});
|
|
applyChanges(baseline, edited);
|
|
if (selectedType === "claude_local") {
|
|
const commonBuild = getUIAdapter("claude_local").buildAdapterConfig;
|
|
const commonBefore = commonBuild(original);
|
|
const commonAfter = commonBuild(configVals);
|
|
for (const key of ["cwd", "instructionsFilePath", "env", "workspaceStrategy", "workspaceRuntime"]) {
|
|
if (overlayValuesEqual(commonBefore[key], commonAfter[key])) continue;
|
|
if (commonAfter[key] === undefined) delete retained[key];
|
|
else retained[key] = commonAfter[key];
|
|
}
|
|
}
|
|
if (original.model !== configVals.model && !configVals.model) delete retained.model;
|
|
if (original.thinkingEffort !== configVals.thinkingEffort) {
|
|
delete retained.reasoningEffort;
|
|
delete retained.effort;
|
|
if (!configVals.thinkingEffort) delete retained.modelReasoningEffort;
|
|
}
|
|
override.adapterConfig = retained;
|
|
} else {
|
|
override.adapterConfig = edited;
|
|
}
|
|
}
|
|
overrides[agent.slug] = override;
|
|
}
|
|
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
|
}
|
|
|
|
const hasSource =
|
|
sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0;
|
|
const hasErrors = importPreview ? importPreview.errors.length > 0 : false;
|
|
|
|
// The local .zip uploads its raw compressed file as multipart and is unzipped
|
|
// server-side, so the old inline-size ceiling no longer gates it. Surface the
|
|
// compressed upload size instead of the inflated-JSON estimate.
|
|
const localCompressedBytes = sourceMode === "local" ? localPackage?.compressedBytes ?? null : null;
|
|
|
|
const previewContent = selectedFile && importPreview
|
|
? (() => {
|
|
return importPreview.files[selectedFile] ?? null;
|
|
})()
|
|
: null;
|
|
const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null;
|
|
|
|
if (importOutcome && importOutcome.kind === "expired") {
|
|
// Soft success: the import finished and wrote all its data, but the job's
|
|
// in-memory result expired before we could read it. Never a failure — the
|
|
// company list has been refreshed, so the imported company is available
|
|
// from the switcher, and when we could read the company we take the user
|
|
// straight to it.
|
|
return (
|
|
<div className="max-w-6xl space-y-4 px-5 py-5">
|
|
<div>
|
|
<h2 className="text-base font-semibold">Import completed</h2>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{importOutcome.companyName
|
|
? <>The import finished and <span className="font-medium text-foreground">{importOutcome.companyName}</span> is ready. Its detailed summary is no longer available.</>
|
|
: "The import finished and your organization is ready. Its detailed summary is no longer available, but the organization has been added — select it from the organization switcher to view it."}
|
|
</p>
|
|
{importOutcome.pausedAutomations ? (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Imported agents arrived paused — resume them from the company's Agents page so assigned tasks can start.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{importOutcome.dashboardPath ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
data-testid="import-expired-open-company"
|
|
// Force a fresh dashboard load so newly imported agents are
|
|
// immediately visible (same reason as the full-outcome CTA).
|
|
onClick={() => window.location.assign(importOutcome.dashboardPath!)}
|
|
>
|
|
Open organization dashboard
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (importOutcome) {
|
|
const { result, dashboardPath } = importOutcome;
|
|
const skillResults = result.skills ?? [];
|
|
const activationItems = importOutcome.pausedAutomations ? buildActivationItems(result) : [];
|
|
const pendingCount = activationItems.filter(
|
|
(item) => activationChecked.has(item.key) && !activatedKeys.has(item.key),
|
|
).length;
|
|
return (
|
|
<div className="max-w-6xl space-y-4 px-5 py-5">
|
|
<div>
|
|
<h2 className="text-base font-semibold">Import complete</h2>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{result.company.name}: {result.agents.length} agent{result.agents.length === 1 ? "" : "s"},{" "}
|
|
{skillResults.length} skill{skillResults.length === 1 ? "" : "s"},{" "}
|
|
{result.projects.length} project{result.projects.length === 1 ? "" : "s"}, and{" "}
|
|
{result.routines.length} routine{result.routines.length === 1 ? "" : "s"} processed.
|
|
</p>
|
|
</div>
|
|
|
|
{skillResults.length > 0 && (
|
|
<div className="rounded-md border border-border">
|
|
<div className="border-b border-border px-4 py-2.5">
|
|
<h3 className="text-sm font-medium">Skill import results</h3>
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{skillResults.map((skill) => (
|
|
<div key={`${skill.originalKey}:${skill.id}`} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
|
<span className="min-w-0 flex-1 truncate">{skill.originalSlug}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">{skill.action}</span>
|
|
{skill.slug !== skill.originalSlug && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">as {skill.slug}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{result.warnings.length > 0 && (
|
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
|
{result.warnings.map((w) => (
|
|
<div key={w} className="text-xs text-amber-500">{w}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{activationItems.length > 0 && (
|
|
<div className="rounded-md border border-border">
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<h3 className="text-sm font-medium">Activate imported agents and routines</h3>
|
|
<span className="text-xs text-muted-foreground">imported paused</span>
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{activationItems.map((item) => {
|
|
const isActivated = activatedKeys.has(item.key);
|
|
const failure = activationFailures[item.key];
|
|
return (
|
|
<label key={item.key} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={activationChecked.has(item.key)}
|
|
disabled={isActivated || isActivating}
|
|
onChange={() => handleToggleActivationItem(item.key)}
|
|
className="accent-foreground"
|
|
/>
|
|
<Badge variant="outline" className={cn(
|
|
"text-(length:--text-nano) uppercase tracking-wide",
|
|
item.kind === "agent"
|
|
? "text-blue-500 border-blue-500/30"
|
|
: "text-purple-500 border-purple-500/30",
|
|
)}>
|
|
{item.kind}
|
|
</Badge>
|
|
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
|
{isActivated ? (
|
|
<span className="shrink-0 text-xs text-emerald-500">activated</span>
|
|
) : failure ? (
|
|
<span className="shrink-0 text-xs text-destructive">failed: {failure}</span>
|
|
) : (
|
|
<span className="shrink-0 text-xs text-muted-foreground">paused</span>
|
|
)}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex justify-end border-t border-border px-4 py-2.5">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void handleActivateSelected()}
|
|
disabled={isActivating || pendingCount === 0}
|
|
>
|
|
{isActivating ? "Activating..." : `Activate selected (${pendingCount})`}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{importOutcome.pausedAutomations ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Anything left paused here stays visible on the company's Agents and Routines pages, which offer the same resume actions — nothing is lost if you leave this page.
|
|
</p>
|
|
) : null}
|
|
|
|
{/* Force a fresh dashboard load so newly imported agents are immediately visible. */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => window.location.assign(dashboardPath)}
|
|
>
|
|
Go to dashboard
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Resuming a job from a previous page load: show a watch panel instead of
|
|
// the stale form. Cleared when the job settles (success renders the
|
|
// outcome above; failure returns to the form with an error toast).
|
|
if (resumedWatchJobId) {
|
|
return (
|
|
<div className="max-w-6xl space-y-4 px-5 py-5">
|
|
<div>
|
|
<h2 className="text-base font-semibold">Resume watching import</h2>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
An import you started earlier is still running on the server.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
|
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">
|
|
Import running on the server — safe to keep waiting; reconnecting won't lose it.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!selectedCompanyId) {
|
|
return <EmptyState icon={Download} message="Select an organization to import into." />;
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-6xl">
|
|
{/* 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 or upload a local Paperclip zip package.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-2 md:grid-cols-2">
|
|
{(
|
|
[
|
|
{ key: "github", icon: GithubIcon, label: "GitHub repo" },
|
|
{ key: "local", icon: Upload, label: "Local zip" },
|
|
] 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",
|
|
importMutation.isPending && "cursor-not-allowed opacity-50",
|
|
)}
|
|
disabled={importMutation.isPending}
|
|
onClick={() => {
|
|
setSourceMode(key);
|
|
resetImportFlowState();
|
|
}}
|
|
>
|
|
<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"
|
|
accept=".zip,application/zip"
|
|
className="hidden"
|
|
onChange={handleChooseLocalPackage}
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => packageInputRef.current?.click()}
|
|
disabled={importMutation.isPending}
|
|
>
|
|
Choose zip
|
|
</Button>
|
|
{localPackage && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{localPackage.name} with{" "}
|
|
{Object.keys(localPackage.files).length} file
|
|
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
|
|
{localCompressedBytes !== null ? ` (${formatMegabytes(localCompressedBytes)} zip)` : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!localPackage && (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
{localZipHelpText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Field
|
|
label="GitHub URL"
|
|
hint="Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)."
|
|
>
|
|
<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="https://github.com/owner/repo/tree/main/company"
|
|
disabled={importMutation.isPending}
|
|
onChange={(e) => {
|
|
setImportUrl(e.target.value);
|
|
resetImportFlowState();
|
|
}}
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
<Field label="Target" hint="Import into this organization 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}
|
|
disabled={importMutation.isPending}
|
|
onChange={(e) => {
|
|
setTargetMode(e.target.value as "existing" | "new");
|
|
resetImportFlowState();
|
|
}}
|
|
>
|
|
<option value="new">Create new organization</option>
|
|
<option value="existing">
|
|
Existing company: {selectedCompany?.name}
|
|
</option>
|
|
</select>
|
|
</Field>
|
|
|
|
{targetMode === "new" && (
|
|
<Field
|
|
label="New organization 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);
|
|
resetMutationState();
|
|
}}
|
|
placeholder="Imported Organization"
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
<Field
|
|
label="Collision strategy"
|
|
hint="Board imports can rename, skip, or replace matching organization content."
|
|
>
|
|
<select
|
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
|
value={collisionStrategy}
|
|
disabled={importMutation.isPending}
|
|
onChange={(e) => {
|
|
setCollisionStrategy(e.target.value as CompanyPortabilityCollisionStrategy);
|
|
resetImportFlowState();
|
|
}}
|
|
>
|
|
<option value="rename">Rename on conflict</option>
|
|
<option value="skip">Skip on conflict</option>
|
|
<option value="replace">Replace existing</option>
|
|
</select>
|
|
</Field>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => previewMutation.mutate(previewGenerationRef.current)}
|
|
disabled={
|
|
previewMutation.isPending || importMutation.isPending || !hasSource
|
|
}
|
|
>
|
|
{previewMutation.isPending ? "Previewing..." : "Preview import"}
|
|
</Button>
|
|
{!hasSource && !previewMutation.isPending && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Choose a package above to enable the preview.
|
|
</span>
|
|
)}
|
|
{importMutation.isPending && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Import in progress — the package and settings unlock when it finishes.
|
|
</span>
|
|
)}
|
|
</div>
|
|
{previewMutation.isPending && (
|
|
<div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
|
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">
|
|
{transferProgress
|
|
? `${formatTransferProgress(transferProgress)} An interrupted upload resumes from the finished parts.`
|
|
: `Uploading and analyzing your package${
|
|
localCompressedBytes !== null ? ` (${formatMegabytes(localCompressedBytes)} zip)` : ""
|
|
} — large packages can take a few minutes. Keep this page open.`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{previewMutation.isError &&
|
|
!previewMutation.isPending &&
|
|
previewMutation.variables === previewGenerationRef.current && (
|
|
<div className="mt-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2.5">
|
|
<p className="text-xs text-destructive">
|
|
Preview failed:{" "}
|
|
{previewMutation.error instanceof Error
|
|
? previewMutation.error.message
|
|
: "the request did not complete."}{" "}
|
|
Retry, or re-export the package without large attachments to shrink it.
|
|
</p>
|
|
</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 gap-4 text-sm">
|
|
<span className="font-medium">
|
|
Import preview
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
|
|
</span>
|
|
{conflicts.length > 0 && (
|
|
<span className="text-amber-500">
|
|
{conflicts.length} conflict{conflicts.length === 1 ? "" : "s"}
|
|
</span>
|
|
)}
|
|
{importPreview.errors.length > 0 && (
|
|
<span className="text-destructive">
|
|
{importPreview.errors.length} error{importPreview.errors.length === 1 ? "" : "s"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Conflict resolution list */}
|
|
<ConflictResolutionList
|
|
conflicts={conflicts}
|
|
nameOverrides={nameOverrides}
|
|
skippedSlugs={skippedSlugs}
|
|
confirmedSlugs={confirmedSlugs}
|
|
onRename={handleConflictRename}
|
|
onToggleSkip={handleConflictToggleSkip}
|
|
onToggleConfirm={handleConflictToggleConfirm}
|
|
/>
|
|
|
|
{/* Adapter picker list */}
|
|
<AdapterPickerList
|
|
agents={adapterAgents}
|
|
adapterOptions={importAdapterOptions}
|
|
adapterOverrides={adapterOverrides}
|
|
expandedSlugs={adapterExpandedSlugs}
|
|
configValues={adapterConfigValues}
|
|
onChangeAdapter={handleAdapterChange}
|
|
onToggleExpand={handleAdapterToggleExpand}
|
|
onChangeConfig={handleAdapterConfigChange}
|
|
/>
|
|
|
|
{/* Import button — below renames */}
|
|
<div className="mx-5 mt-3 flex flex-wrap items-center justify-end gap-3">
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={pauseAutomations}
|
|
onChange={(e) => {
|
|
setPauseAutomations(e.target.checked);
|
|
resetMutationState();
|
|
}}
|
|
className="accent-foreground"
|
|
/>
|
|
Start imported agents and routines paused
|
|
</label>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => importMutation.mutate({ previewForImport: importPreview, pauseAutomations })}
|
|
disabled={importMutation.isPending || hasErrors || selectedCount === 0}
|
|
>
|
|
<Download className="mr-1.5 h-3.5 w-3.5" />
|
|
{importMutation.isPending
|
|
? "Importing..."
|
|
: `Import ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
|
|
</Button>
|
|
</div>
|
|
{importMutation.isPending && (
|
|
<div className="mx-5 mt-3 flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">
|
|
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">
|
|
{transferProgress
|
|
? `${formatTransferProgress(transferProgress)} An interrupted upload resumes from the finished parts.`
|
|
: "Import running on the server — safe to keep waiting; reconnecting won't lose it. Large packages can take several minutes."}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{importMutation.isError && !importMutation.isPending && (
|
|
<div className="mx-5 mt-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2.5">
|
|
<p className="text-xs text-destructive">
|
|
Import failed:{" "}
|
|
{importMutation.error instanceof Error
|
|
? importMutation.error.message
|
|
: "the request did not complete."}{" "}
|
|
Nothing may have been created, or the import stopped partway — check the target company
|
|
before retrying.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Warnings */}
|
|
{importPreview.warnings.length > 0 && (
|
|
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
|
{importPreview.warnings.map((w) => (
|
|
<div key={w} className="text-xs text-amber-500">{w}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors */}
|
|
{importPreview.errors.length > 0 && (
|
|
<div className="mx-5 mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3">
|
|
{importPreview.errors.map((e) => (
|
|
<div key={e} className="text-xs text-destructive">{e}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Two-column layout */}
|
|
<div className="grid gap-4 xl:h-(--sz-calc-31) xl:grid-cols-(--gtc-25) xl:gap-0">
|
|
<aside className="flex max-h-(--sz-24rem) flex-col overflow-hidden border-b border-border xl:max-h-none xl:border-b-0 xl:border-r">
|
|
<div className="border-b border-border px-4 py-3 shrink-0">
|
|
<h2 className="text-base font-semibold">Package files</h2>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<FileTree
|
|
nodes={tree}
|
|
selectedFile={selectedFile}
|
|
expandedDirs={expandedDirs}
|
|
checkedFiles={checkedFiles}
|
|
onToggleDir={handleToggleDir}
|
|
onSelectFile={setSelectedFile}
|
|
onToggleCheck={handleToggleCheck}
|
|
renderFileExtra={(node, checked) => renderImportFileExtra(node, checked, renameMap)}
|
|
fileRowClassName={importFileRowClassName}
|
|
wrapLabels={false}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
<div className="min-w-0 overflow-y-auto xl:pl-6">
|
|
<ImportPreviewPane
|
|
selectedFile={selectedFile}
|
|
content={previewContent}
|
|
allFiles={importPreview?.files ?? {}}
|
|
action={selectedAction}
|
|
renamedTo={selectedFile ? (renameMap.get(selectedFile) ?? null) : null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|