import { useEffect, useState, useRef, useCallback, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { companiesApi } from "../api/companies"; import { goalsApi } from "../api/goals"; import { agentsApi } from "../api/agents"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { Dialog, DialogPortal } from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils"; import { getUIAdapter } from "../adapters"; import { defaultCreateValues } from "./agent-config-defaults"; import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { ChoosePathButton } from "./PathInstructionsModal"; import { HintIcon } from "./agent-config-primitives"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { Building2, Bot, Code, Gem, ListTodo, Rocket, ArrowLeft, ArrowRight, Terminal, Sparkles, MousePointer2, Check, Loader2, FolderOpen, ChevronDown, X } from "lucide-react"; type Step = 1 | 2 | 3 | 4; type AdapterType = | "claude_local" | "codex_local" | "gemini_local" | "opencode_local" | "pi_local" | "cursor" | "process" | "http" | "openclaw_gateway"; const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOULD.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`; export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const navigate = useNavigate(); const initialStep = onboardingOptions.initialStep ?? 1; const existingCompanyId = onboardingOptions.companyId; const [step, setStep] = useState(initialStep); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [modelOpen, setModelOpen] = useState(false); const [modelSearch, setModelSearch] = useState(""); // Step 1 const [companyName, setCompanyName] = useState(""); const [companyGoal, setCompanyGoal] = useState(""); // Step 2 const [agentName, setAgentName] = useState("CEO"); const [adapterType, setAdapterType] = useState("claude_local"); const [cwd, setCwd] = useState(""); const [model, setModel] = useState(""); const [command, setCommand] = useState(""); const [args, setArgs] = useState(""); const [url, setUrl] = useState(""); const [adapterEnvResult, setAdapterEnvResult] = useState(null); const [adapterEnvError, setAdapterEnvError] = useState(null); const [adapterEnvLoading, setAdapterEnvLoading] = useState(false); const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] = useState(false); const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false); const [showMoreAdapters, setShowMoreAdapters] = useState(false); // Step 3 const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md"); const [taskDescription, setTaskDescription] = useState( DEFAULT_TASK_DESCRIPTION ); // Auto-grow textarea for task description const textareaRef = useRef(null); const autoResizeTextarea = useCallback(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }, []); // Created entity IDs — pre-populate from existing company when skipping step 1 const [createdCompanyId, setCreatedCompanyId] = useState( existingCompanyId ?? null ); const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState< string | null >(null); const [createdAgentId, setCreatedAgentId] = useState(null); const [createdIssueRef, setCreatedIssueRef] = useState(null); // Sync step and company when onboarding opens with options. // Keep this independent from company-list refreshes so Step 1 completion // doesn't get reset after creating a company. useEffect(() => { if (!onboardingOpen) return; const cId = onboardingOptions.companyId ?? null; setStep(onboardingOptions.initialStep ?? 1); setCreatedCompanyId(cId); setCreatedCompanyPrefix(null); }, [ onboardingOpen, onboardingOptions.companyId, onboardingOptions.initialStep ]); // Backfill issue prefix for an existing company once companies are loaded. useEffect(() => { if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return; const company = companies.find((c) => c.id === createdCompanyId); if (company) setCreatedCompanyPrefix(company.issuePrefix); }, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]); // Resize textarea when step 3 is shown or description changes useEffect(() => { if (step === 3) autoResizeTextarea(); }, [step, taskDescription, autoResizeTextarea]); const { data: adapterModels, error: adapterModelsError, isLoading: adapterModelsLoading, isFetching: adapterModelsFetching } = useQuery({ queryKey: createdCompanyId ? queryKeys.agents.adapterModels(createdCompanyId, adapterType) : ["agents", "none", "adapter-models", adapterType], queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 }); const isLocalAdapter = adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "opencode_local" || adapterType === "cursor"; const effectiveAdapterCommand = command.trim() || (adapterType === "codex_local" ? "codex" : adapterType === "gemini_local" ? "gemini" : adapterType === "cursor" ? "agent" : adapterType === "opencode_local" ? "opencode" : "claude"); useEffect(() => { if (step !== 2) return; setAdapterEnvResult(null); setAdapterEnvError(null); }, [step, adapterType, cwd, model, command, args, url]); const selectedModel = (adapterModels ?? []).find((m) => m.id === model); const hasAnthropicApiKeyOverrideCheck = adapterEnvResult?.checks.some( (check) => check.code === "claude_anthropic_api_key_overrides_subscription" ) ?? false; const shouldSuggestUnsetAnthropicApiKey = adapterType === "claude_local" && adapterEnvResult?.status === "fail" && hasAnthropicApiKeyOverrideCheck; const filteredModels = useMemo(() => { const query = modelSearch.trim().toLowerCase(); return (adapterModels ?? []).filter((entry) => { if (!query) return true; const provider = extractProviderIdWithFallback(entry.id, ""); return ( entry.id.toLowerCase().includes(query) || entry.label.toLowerCase().includes(query) || provider.toLowerCase().includes(query) ); }); }, [adapterModels, modelSearch]); const groupedModels = useMemo(() => { if (adapterType !== "opencode_local") { return [ { provider: "models", entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)) } ]; } const groups = new Map>(); for (const entry of filteredModels) { const provider = extractProviderIdWithFallback(entry.id); const bucket = groups.get(provider) ?? []; bucket.push(entry); groups.set(provider, bucket); } return Array.from(groups.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([provider, entries]) => ({ provider, entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)) })); }, [filteredModels, adapterType]); function reset() { setStep(1); setLoading(false); setError(null); setCompanyName(""); setCompanyGoal(""); setAgentName("CEO"); setAdapterType("claude_local"); setCwd(""); setModel(""); setCommand(""); setArgs(""); setUrl(""); setAdapterEnvResult(null); setAdapterEnvError(null); setAdapterEnvLoading(false); setForceUnsetAnthropicApiKey(false); setUnsetAnthropicLoading(false); setTaskTitle("Create your CEO HEARTBEAT.md"); setTaskDescription(DEFAULT_TASK_DESCRIPTION); setCreatedCompanyId(null); setCreatedCompanyPrefix(null); setCreatedAgentId(null); setCreatedIssueRef(null); } function handleClose() { reset(); closeOnboarding(); } function buildAdapterConfig(): Record { const adapter = getUIAdapter(adapterType); const config = adapter.buildAdapterConfig({ ...defaultCreateValues, adapterType, cwd, model: adapterType === "codex_local" ? model || DEFAULT_CODEX_LOCAL_MODEL : adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL : adapterType === "cursor" ? model || DEFAULT_CURSOR_LOCAL_MODEL : model, command, args, url, dangerouslySkipPermissions: adapterType === "claude_local", dangerouslyBypassSandbox: adapterType === "codex_local" ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX : defaultCreateValues.dangerouslyBypassSandbox }); if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? { ...(config.env as Record) } : {}; env.ANTHROPIC_API_KEY = { type: "plain", value: "" }; config.env = env; } return config; } async function runAdapterEnvironmentTest( adapterConfigOverride?: Record ): Promise { if (!createdCompanyId) { setAdapterEnvError( "Create or select a company before testing adapter environment." ); return null; } setAdapterEnvLoading(true); setAdapterEnvError(null); try { const result = await agentsApi.testEnvironment( createdCompanyId, adapterType, { adapterConfig: adapterConfigOverride ?? buildAdapterConfig() } ); setAdapterEnvResult(result); return result; } catch (err) { setAdapterEnvError( err instanceof Error ? err.message : "Adapter environment test failed" ); return null; } finally { setAdapterEnvLoading(false); } } async function handleStep1Next() { setLoading(true); setError(null); try { const company = await companiesApi.create({ name: companyName.trim() }); setCreatedCompanyId(company.id); setCreatedCompanyPrefix(company.issuePrefix); setSelectedCompanyId(company.id); queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); if (companyGoal.trim()) { const parsedGoal = parseOnboardingGoalInput(companyGoal); await goalsApi.create(company.id, { title: parsedGoal.title, ...(parsedGoal.description ? { description: parsedGoal.description } : {}), level: "company", status: "active" }); queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(company.id) }); } setStep(2); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create company"); } finally { setLoading(false); } } async function handleStep2Next() { if (!createdCompanyId) return; setLoading(true); setError(null); try { if (adapterType === "opencode_local") { const selectedModelId = model.trim(); if (!selectedModelId) { setError( "OpenCode requires an explicit model in provider/model format." ); return; } if (adapterModelsError) { setError( adapterModelsError instanceof Error ? adapterModelsError.message : "Failed to load OpenCode models." ); return; } if (adapterModelsLoading || adapterModelsFetching) { setError( "OpenCode models are still loading. Please wait and try again." ); return; } const discoveredModels = adapterModels ?? []; if (!discoveredModels.some((entry) => entry.id === selectedModelId)) { setError( discoveredModels.length === 0 ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." : `Configured OpenCode model is unavailable: ${selectedModelId}` ); return; } } if (isLocalAdapter) { const result = adapterEnvResult ?? (await runAdapterEnvironmentTest()); if (!result) return; } const agent = await agentsApi.create(createdCompanyId, { name: agentName.trim(), role: "ceo", adapterType, adapterConfig: buildAdapterConfig(), runtimeConfig: { heartbeat: { enabled: true, intervalSec: 3600, wakeOnDemand: true, cooldownSec: 10, maxConcurrentRuns: 1 } } }); setCreatedAgentId(agent.id); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) }); setStep(3); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create agent"); } finally { setLoading(false); } } async function handleUnsetAnthropicApiKey() { if (!createdCompanyId || unsetAnthropicLoading) return; setUnsetAnthropicLoading(true); setError(null); setAdapterEnvError(null); setForceUnsetAnthropicApiKey(true); const configWithUnset = (() => { const config = buildAdapterConfig(); const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? { ...(config.env as Record) } : {}; env.ANTHROPIC_API_KEY = { type: "plain", value: "" }; config.env = env; return config; })(); try { if (createdAgentId) { await agentsApi.update( createdAgentId, { adapterConfig: configWithUnset }, createdCompanyId ); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) }); } const result = await runAdapterEnvironmentTest(configWithUnset); if (result?.status === "fail") { setError( "Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing." ); } } catch (err) { setError( err instanceof Error ? err.message : "Failed to unset ANTHROPIC_API_KEY and retry." ); } finally { setUnsetAnthropicLoading(false); } } async function handleStep3Next() { if (!createdCompanyId || !createdAgentId) return; setLoading(true); setError(null); try { const issue = await issuesApi.create(createdCompanyId, { title: taskTitle.trim(), ...(taskDescription.trim() ? { description: taskDescription.trim() } : {}), assigneeAgentId: createdAgentId, status: "todo" }); setCreatedIssueRef(issue.identifier ?? issue.id); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(createdCompanyId) }); setStep(4); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create task"); } finally { setLoading(false); } } async function handleLaunch() { if (!createdAgentId) return; setLoading(true); setError(null); setLoading(false); reset(); closeOnboarding(); if (createdCompanyPrefix) { navigate(`/${createdCompanyPrefix}/dashboard`); return; } navigate("/dashboard"); } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (step === 1 && companyName.trim()) handleStep1Next(); else if (step === 2 && agentName.trim()) handleStep2Next(); else if (step === 3 && taskTitle.trim()) handleStep3Next(); else if (step === 4) handleLaunch(); } } if (!onboardingOpen) return null; return ( { if (!open) handleClose(); }} > {/* Plain div instead of DialogOverlay — Radix's overlay wraps in RemoveScroll which blocks wheel events on our custom (non-DialogContent) scroll container. A plain div preserves the background without scroll-locking. */}
{/* Close button */} {/* Left half — form */}
{/* Progress tabs */}
{( [ { step: 1 as Step, label: "Company", icon: Building2 }, { step: 2 as Step, label: "Agent", icon: Bot }, { step: 3 as Step, label: "Task", icon: ListTodo }, { step: 4 as Step, label: "Launch", icon: Rocket } ] as const ).map(({ step: s, label, icon: Icon }) => ( ))}
{/* Step content */} {step === 1 && (

Name your company

This is the organization your agents will work for.

setCompanyName(e.target.value)} autoFocus />