import { useEffect, useState, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; import { useLocation, useNavigate, useParams } from "@/lib/router"; 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 { approvalsApi } from "../api/approvals"; 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 { listUIAdapters } from "../adapters"; import { isVisualAdapterChoice } from "../adapters/metadata"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; import { defaultCreateValues } from "./agent-config-defaults"; import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; import { composeCeoInstructions } from "../lib/ceo-instructions"; import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } 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 { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local"; import { resolveRouteOnboardingOptions } from "../lib/onboarding-route"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { FrontDoor } from "./FrontDoor"; import { AgentCapsule } from "./AgentCapsule"; import { Building2, Bot, ListTodo, ArrowLeft, ArrowRight, Sparkles, Check, Loader2, ChevronDown, X } from "lucide-react"; type Step = 0 | 1 | 2 | 3 | 4 | 5; // Plugin/external adapters use arbitrary type ids, so this mirrors the master // wizard's registry-driven approach rather than a fixed union. type AdapterType = string; const MISSION_PROMPT_CHIPS = [ "Build a SaaS product", "Scale a content business", "Launch a marketplace" ]; function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: string): string { const parts: string[] = []; if (q1.trim()) parts.push(q1.trim()); if (q2.trim()) parts.push(`We serve ${q2.trim().toLowerCase()}.`); if (q3.trim()) parts.push(`Our biggest challenge is ${q3.trim().toLowerCase()}.`); if (q4.trim()) parts.push(`Success looks like ${q4.trim().toLowerCase()}.`); return parts.join(" "); } const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state"; function loadSavedState(): Record | null { try { const raw = localStorage.getItem(ONBOARDING_STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding, onboardingRouteDismissed: routeDismissed, setOnboardingRouteDismissed: setRouteDismissed, } = useDialog(); const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const { companyPrefix } = useParams<{ companyPrefix?: string }>(); // Sync disabled adapter types from server so the adapter grid filters them out. const disabledTypes = useDisabledAdaptersSync(); // Support opening the wizard from a route (e.g. /onboarding or an existing // company's "add agent" entry point) in addition to the dialog context. const routeOnboardingOptions = companyPrefix && companiesLoading ? null : resolveRouteOnboardingOptions({ pathname: location.pathname, companyPrefix, companies, }); const effectiveOnboardingOpen = onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed); const effectiveOnboardingOptions = onboardingOpen ? onboardingOptions : routeOnboardingOptions ?? {}; const initialStep = effectiveOnboardingOptions.initialStep ?? 0; const existingCompanyId = effectiveOnboardingOptions.companyId; // Restore saved state from localStorage (read once on mount) const saved = useMemo(loadSavedState, []); const [step, setStep] = useState((saved?.step as Step) ?? initialStep); const [onboardingPath, setOnboardingPath] = useState<"create" | "grow" | null>((saved?.onboardingPath as "create" | "grow" | null) ?? null); // "Grow existing" questionnaire fields const [growWorkflows, setGrowWorkflows] = useState((saved?.growWorkflows as string) ?? ""); const [growPainPoints, setGrowPainPoints] = useState((saved?.growPainPoints as string) ?? ""); const [growAutomate, setGrowAutomate] = useState((saved?.growAutomate as string) ?? ""); 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((saved?.companyName as string) ?? ""); const [companyGoal, setCompanyGoal] = useState((saved?.companyGoal as string) ?? ""); const [missionPath, setMissionPath] = useState<"direct" | "questionnaire" | null>((saved?.missionPath as "direct" | "questionnaire" | null) ?? null); const [missionConfirmed, setMissionConfirmed] = useState((saved?.missionConfirmed as boolean) ?? false); // Questionnaire answers const [q1, setQ1] = useState((saved?.q1 as string) ?? ""); // What do you do? const [q2, setQ2] = useState((saved?.q2 as string) ?? ""); // Who do you serve? const [q3, setQ3] = useState((saved?.q3 as string) ?? ""); // Biggest bottleneck? const [q4, setQ4] = useState((saved?.q4 as string) ?? ""); // What would success look like? // Step 2 const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "Chief of staff"); const [adapterType, setAdapterType] = useState((saved?.adapterType as AdapterType) ?? "claude_local"); const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); const [model, setModel] = useState((saved?.model as string) ?? ""); const [command, setCommand] = useState((saved?.command as string) ?? ""); const [args, setArgs] = useState((saved?.args as string) ?? ""); const [url, setUrl] = useState((saved?.url as string) ?? ""); 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); // Created entity IDs — pre-populate from existing company when skipping step 1 const [createdCompanyId, setCreatedCompanyId] = useState( existingCompanyId ?? (saved?.createdCompanyId as string) ?? null ); const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState< string | null >((saved?.createdCompanyPrefix as string) ?? null); const [createdAgentId, setCreatedAgentId] = useState((saved?.createdAgentId as string) ?? null); // Reset the route-dismissed flag when navigating to a different path. useEffect(() => { setRouteDismissed(false); }, [location.pathname]); // Sync step and company when onboarding opens with explicit options. // Only override saved state when explicit options provide values. useEffect(() => { if (!effectiveOnboardingOpen) return; // If explicit options are provided, they take precedence over saved state if (effectiveOnboardingOptions.initialStep) { setStep(effectiveOnboardingOptions.initialStep); } if (effectiveOnboardingOptions.companyId) { setCreatedCompanyId(effectiveOnboardingOptions.companyId); setCreatedCompanyPrefix(null); } }, [ effectiveOnboardingOpen, effectiveOnboardingOptions.companyId, effectiveOnboardingOptions.initialStep ]); // Backfill issue prefix for an existing company once companies are loaded. useEffect(() => { if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return; const company = companies.find((c) => c.id === createdCompanyId); if (company) setCreatedCompanyPrefix(company.issuePrefix); }, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]); // Persist wizard state to localStorage on every change useEffect(() => { if (!effectiveOnboardingOpen) return; const state = { step, companyName, companyGoal, missionPath, missionConfirmed, q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url, createdCompanyId, createdCompanyPrefix, createdAgentId, onboardingPath, growWorkflows, growPainPoints, growAutomate, }; localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(state)); }, [ effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed, q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url, createdCompanyId, createdCompanyPrefix, createdAgentId, onboardingPath, growWorkflows, growPainPoints, growAutomate, ]); const { data: adapterModels, error: adapterModelsError, isLoading: adapterModelsLoading, isFetching: adapterModelsFetching } = useQuery({ // The wizard doesn't expose an environment selector, so models always // resolve against the local Paperclip host (environmentId = null). queryKey: createdCompanyId ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null) : ["agents", "none", "adapter-models", adapterType, null], queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }), // Models are picked on step 4 (Connect a model). enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 }); const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(adapterType); const isLocalAdapterCaps = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; const isLocalAdapter = isLocalAdapterCaps || adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; // Build adapter grids dynamically from the UI registry + display metadata. // External/plugin adapters automatically appear with generic defaults, and // server-disabled types are filtered out. const { recommendedAdapters, moreAdapters } = useMemo(() => { const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]); const all = listUIAdapters() .filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && !disabledTypes.has(a.type) && isVisualAdapterChoice(a.type) ) .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type })); return { recommendedAdapters: all.filter((a) => a.recommended), moreAdapters: all.filter((a) => !a.recommended), }; }, [disabledTypes]); const COMMAND_PLACEHOLDERS: Record = { claude_local: "claude", codex_local: "codex", gemini_local: "gemini", hermes_local: "hermes", pi_local: "pi", cursor: "agent", opencode_local: "opencode", }; const effectiveAdapterCommand = command.trim() || (COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, "")); useEffect(() => { if (step !== 4) return; setAdapterEnvResult(null); setAdapterEnvError(null); }, [step, adapterType, 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() { localStorage.removeItem(ONBOARDING_STORAGE_KEY); setStep(0); setOnboardingPath(null); setGrowWorkflows(""); setGrowPainPoints(""); setGrowAutomate(""); setLoading(false); setError(null); setCompanyName(""); setCompanyGoal(""); setMissionPath(null); setMissionConfirmed(false); setQ1(""); setQ2(""); setQ3(""); setQ4(""); setAgentName("Chief of staff"); setAdapterType("claude_local"); setModel(""); setCommand(""); setArgs(""); setUrl(""); setAdapterEnvResult(null); setAdapterEnvError(null); setAdapterEnvLoading(false); setForceUnsetAnthropicApiKey(false); setUnsetAnthropicLoading(false); setCreatedCompanyId(null); setCreatedCompanyPrefix(null); setCreatedAgentId(null); } function handleClose() { reset(); closeOnboarding(); // On the /onboarding route the wizard is also kept open by the route // itself, so closing the dialog must mark the route dismissed — otherwise // effectiveOnboardingOpen stays true and the wizard re-renders instead of // handing off to the launcher card (PAP-52). setRouteDismissed(true); } function handleLaunchToChat() { const prefix = createdCompanyPrefix; reset(); closeOnboarding(); navigate(prefix ? `/${prefix}/board-chat` : "/dashboard"); } function buildAdapterConfig(): Record { const adapter = getUIAdapter(adapterType); const config = adapter.buildAdapterConfig({ ...defaultCreateValues, adapterType, model: adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL : adapterType === "cursor" ? model || DEFAULT_CURSOR_LOCAL_MODEL : adapterType === "opencode_local" ? model || DEFAULT_OPENCODE_LOCAL_MODEL : model, command, args, url, dangerouslySkipPermissions: adapterType === "claude_local" || adapterType === "opencode_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); } } // Step 2 → 3 ("Confirm mission"): create the company + its company-level // goal, then advance to naming the team lead. Guarded so revisiting the // mission step (e.g. via Back) doesn't create a duplicate company. async function handleConfirmMission() { if (createdCompanyId) { setStep(3); return; } 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 }); 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(3); // → Create your team lead } catch (err) { setError(err instanceof Error ? err.message : "Failed to create company"); } finally { setLoading(false); } } // Step 4 → 5 ("Give it a heartbeat"): hire the lead agent + seed its // instructions, then advance to Review. Guarded so revisiting step 4 // doesn't hire a second agent. async function handleGiveHeartbeat() { if (!createdCompanyId) return; if (createdAgentId) { setStep(5); return; } setLoading(true); setError(null); try { if (adapterType === "opencode_local") { const selectedModelId = model.trim(); if (!isValidOpenCodeModelId(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 hire = await agentsApi.hire(createdCompanyId, { name: agentName.trim(), role: "ceo", adapterType, adapterConfig: buildAdapterConfig(), runtimeConfig: buildNewAgentRuntimeConfig() }); if (hire.approval) { await approvalsApi.approve( hire.approval.id, "Approved during onboarding first-agent setup." ); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(createdCompanyId) }); } const agent = hire.agent; setCreatedAgentId(agent.id); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) }); // Seed the CEO's agent instructions file so the agent always has // company context + a hiring-plan output format rule. Non-fatal on // failure — the agent can still function with adapter defaults. try { const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId); await agentsApi.saveInstructionsFile( agent.id, { path: bundle.entryFile, content: composeCeoInstructions({ companyName, companyGoal, growPath: onboardingPath === "grow", growWorkflows, growPainPoints, growAutomate, q1, q2, q3, q4, }), }, createdCompanyId, ); } catch (err) { console.warn("Failed to seed CEO instructions:", err); } // Advance to the Review step — the lead is now online. The user drives // strategy + hiring from the planning chat after "Get started". setStep(5); } 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); } } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (step === 0) return; // front door requires click if (step === 1 && companyName.trim()) setStep(2); else if (step === 2 && companyName.trim() && companyGoal.trim()) handleConfirmMission(); else if (step === 3 && agentName.trim()) setStep(4); else if (step === 4 && agentName.trim()) handleGiveHeartbeat(); else if (step === 5) handleLaunchToChat(); } } if (!effectiveOnboardingOpen) 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 */} {/* Step 0: Front Door — full-screen choice */} {step === 0 && (
{ setOnboardingPath(path); setStep(1); }} />
)} {/* Left half — form (steps 1+) */} {step !== 0 && (
{/* 5-segment progress bar (brand .wsteps/.wstep) — segment N filled once step ≥ N. Completed segments jump back. */}
{([1, 2, 3, 4, 5] as const).map((s) => { const filled = step >= s; const canJump = s < step; return (
{/* Persistent evolving capsule (steps 3–5): a single AgentCapsule held in the same tree slot so React reuses the DOM node and the morph reads as one capsule coming to life — dashed slot → solid (configured) → liquid fill + blue glow (online). */} {step >= 3 && step <= 5 && (
{step === 5 ? ( ) : ( )}

{step === 3 ? "Create your team lead" : step === 4 ? "Connect a model" : "Review"}

{step === 3 ? ( <> Name your lead. They'll help drive{" "} {companyName}{" "} toward its mission. We default to{" "} Chief of staff — rename it to anything you like. ) : step === 4 ? ( <>Pick the adapter and model your lead will run on, then check the environment. ) : ( <>Everything's set up — your team lead is online and ready to work. )}

{step === 3 ? ( "an empty slot for an agent" ) : step === 4 ? ( "your team lead, taking shape" ) : ( <> {agentName}{" "} is online and ready to work! )}

)} {/* Step content */} {step === 2 && onboardingPath === "grow" && (

Tell us about your team

We'll use this to set up your lead agent and plan which agents to add.

setQ1(e.target.value)} />