+ );
+}
diff --git a/ui/src/components/OnboardingWizardVariant.test.tsx b/ui/src/components/OnboardingWizardVariant.test.tsx
index b52aeda4a2..0381e60698 100644
--- a/ui/src/components/OnboardingWizardVariant.test.tsx
+++ b/ui/src/components/OnboardingWizardVariant.test.tsx
@@ -3,51 +3,25 @@
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-// The shell reads dialog + company state and renders CloudOnboardingFlow. Mock those
-// so the test exercises the shell's gating + option→prop mapping in isolation.
-const dialogState = vi.hoisted(() => ({ value: null as unknown }));
-vi.mock("@/context/DialogContext", () => ({
- useDialog: () => dialogState.value,
-}));
-vi.mock("@/context/CompanyContext", () => ({
- useCompany: () => ({
- companies: [{ id: "c1", issuePrefix: "ACME", name: "Acme" }],
- loading: false,
- }),
-}));
-vi.mock("@/lib/router", () => ({
- useLocation: () => ({ pathname: "/dashboard" }),
- useParams: () => ({}),
-}));
-vi.mock("./onboarding/CloudOnboardingFlow", () => ({
- CloudOnboardingFlow: (props: { initialStep?: string; existingCompany?: { id: string } }) => (
-
- ),
-}));
-
import { OnboardingWizardVariant } from "./OnboardingWizardVariant";
-function baseDialog(overrides: Record = {}) {
- return {
- onboardingOpen: false,
- onboardingOptions: {},
- closeOnboarding: vi.fn(),
- onboardingRouteDismissed: true,
- setOnboardingRouteDismissed: vi.fn(),
- ...overrides,
- };
-}
+const mockInstanceSettingsApi = vi.hoisted(() => ({
+ getExperimental: vi.fn(),
+}));
-describe("OnboardingWizardVariant", () => {
+vi.mock("@/api/instanceSettings", () => ({
+ instanceSettingsApi: mockInstanceSettingsApi,
+}));
+
+vi.mock("./OnboardingWizard", () => ({
+ OnboardingWizard: () => ,
+}));
+
+describe("OnboardingWizardVariant (PAP-138)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
- function render() {
+ function renderVariant() {
root = createRoot(container);
flushSync(() => {
root!.render();
@@ -68,29 +42,11 @@ describe("OnboardingWizardVariant", () => {
vi.clearAllMocks();
});
- it("renders nothing when onboarding is closed", () => {
- dialogState.value = baseDialog();
- render();
- expect(container.querySelector('[data-testid="onboarding-flow"]')).toBeNull();
- });
+ it("renders the capsule wizard without reading the chat flag", () => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({});
+ renderVariant();
- it("renders the flow at the start step for a fresh open", () => {
- dialogState.value = baseDialog({ onboardingOpen: true, onboardingOptions: {} });
- render();
- const el = container.querySelector('[data-testid="onboarding-flow"]');
- expect(el).not.toBeNull();
- expect(el?.getAttribute("data-initial-step")).toBe("start");
- expect(el?.getAttribute("data-company")).toBe("");
- });
-
- it("starts at the agent step with the company preset when adding to an existing company", () => {
- dialogState.value = baseDialog({
- onboardingOpen: true,
- onboardingOptions: { initialStep: 2, companyId: "c1" },
- });
- render();
- const el = container.querySelector('[data-testid="onboarding-flow"]');
- expect(el?.getAttribute("data-initial-step")).toBe("agent");
- expect(el?.getAttribute("data-company")).toBe("c1");
+ expect(container.querySelector('[data-testid="wizard-capsule"]')).not.toBeNull();
+ expect(mockInstanceSettingsApi.getExperimental).not.toHaveBeenCalled();
});
});
diff --git a/ui/src/components/OnboardingWizardVariant.tsx b/ui/src/components/OnboardingWizardVariant.tsx
index 1254edb84e..a6a6ee3cc0 100644
--- a/ui/src/components/OnboardingWizardVariant.tsx
+++ b/ui/src/components/OnboardingWizardVariant.tsx
@@ -1,72 +1,10 @@
-import { useEffect } from "react";
-import { useLocation, useParams } from "@/lib/router";
-import { useDialog } from "@/context/DialogContext";
-import { useCompany } from "@/context/CompanyContext";
-import { resolveRouteOnboardingOptions } from "@/lib/onboarding-route";
-import { CloudOnboardingFlow } from "./onboarding/CloudOnboardingFlow";
+import { OnboardingWizard } from "./OnboardingWizard";
/**
- * App-integration shell for onboarding. Owns the open/close + route logic and
- * renders the CloudOnboardingFlow (the real app targets the cloud flow; the
- * local flow is harness-only for now). Onboarding opens either explicitly via
- * the dialog context (openOnboarding) or automatically on the /onboarding route
- * (and its /:companyPrefix/onboarding "add an agent" variant).
+ * Default onboarding wizard. Conference-room chat is now the only surface left
+ * behind `enableConferenceRoomChat`; onboarding stays available without that
+ * experimental flag.
*/
export function OnboardingWizardVariant() {
- const {
- onboardingOpen,
- onboardingOptions,
- closeOnboarding,
- onboardingRouteDismissed,
- setOnboardingRouteDismissed,
- } = useDialog();
- const { companies, loading: companiesLoading } = useCompany();
- const location = useLocation();
- const { companyPrefix } = useParams<{ companyPrefix?: string }>();
-
- // Reset the route-dismissed flag when navigating to a different path, so the
- // route can re-open onboarding after the user has dismissed it elsewhere.
- useEffect(() => {
- setOnboardingRouteDismissed(false);
- }, [location.pathname]);
-
- // Support opening from the /onboarding route in addition to the dialog. Wait
- // for companies to load before resolving a company-scoped route so we don't
- // momentarily treat an existing-company entry as a fresh one.
- const routeOptions =
- companyPrefix && companiesLoading
- ? null
- : resolveRouteOnboardingOptions({
- pathname: location.pathname,
- companyPrefix,
- companies,
- });
-
- const open = onboardingOpen || (routeOptions !== null && !onboardingRouteDismissed);
- if (!open) return null;
-
- const options = onboardingOpen ? onboardingOptions : routeOptions ?? {};
- const existingCompany = options.companyId
- ? companies.find((company) => company.id === options.companyId)
- : undefined;
-
- function handleClose() {
- closeOnboarding();
- // On the /onboarding route the shell is also kept open by the route itself,
- // so closing must mark the route dismissed — otherwise `open` stays true and
- // the flow re-renders instead of handing back to the launcher card (PAP-52).
- setOnboardingRouteDismissed(true);
- }
-
- return (
-
- );
+ return ;
}
diff --git a/ui/src/components/onboarding/AgentPreview.tsx b/ui/src/components/onboarding/AgentPreview.tsx
deleted file mode 100644
index 0ca3a1c38a..0000000000
--- a/ui/src/components/onboarding/AgentPreview.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { motion } from "motion/react";
-import { PREVIEW_REVEAL_DURATION, STEP_EASE } from "./onboarding-motion";
-
-/**
- * Name/role preview under the capsule (agent + task steps). Collapsed (zero
- * height) until an identity exists, so the step opens compact; on selection the
- * block grows to full height — the viewport-centered card re-centers each
- * frame, so the capsule slides smoothly up into place — while the labels fade
- * in without moving. Slide and fade share one duration; the fade starts after
- * 25% of it. Both lines keep fixed heights once visible so typing a name
- * afterwards never shifts the layout again.
- */
-export function AgentPreview({
- agentName,
- agentRole,
-}: {
- agentName: string;
- agentRole: string;
-}) {
- const previewVisible = Boolean(agentName || agentRole);
- return (
-
-
-
- {agentName || " "}
-
-
- {agentRole || " "}
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/CloudOnboardingFlow.tsx b/ui/src/components/onboarding/CloudOnboardingFlow.tsx
deleted file mode 100644
index 0bf5d3ac20..0000000000
--- a/ui/src/components/onboarding/CloudOnboardingFlow.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-// Cloud onboarding flow — the thin container that owns state + backend wiring
-// and composes the shared step views inside the shared OnboardingScaffold.
-// Advances Start → Company → Agent → Task, then launches the first task and
-// opens the dashboard. The local flow (LocalOnboardingFlow) reuses the same
-// shared steps + shell and inserts its extra steps.
-import { useState } from "react";
-import { useNavigate } from "@/lib/router";
-import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
-import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
-import { OnboardingScaffold } from "./OnboardingScaffold";
-import { StartStep } from "./steps/StartStep";
-import { CompanyStep } from "./steps/CompanyStep";
-import { AgentStep } from "./steps/AgentStep";
-import { TaskStep } from "./steps/TaskStep";
-
-type Step = "start" | "company" | "agent" | "task";
-
-const DEFAULT_ADAPTER = "claude_local";
-
-// Numbered steps drive the Stepper position ("Step N of M").
-const NUMBERED: Step[] = ["company", "agent", "task"];
-function stepper(s: Step) {
- return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
-}
-
-export interface CloudOnboardingFlowProps {
- /** Called when the user dismisses onboarding. */
- onClose?: () => void;
- /** Starting step. Defaults to "start". Used by the standalone preview harness. */
- initialStep?: Step;
- /**
- * Preview-only: skip all backend calls (company/agent/task creation) so the
- * flow can be clicked end-to-end without a backend. Used by the standalone
- * preview harness — never enabled in the real app.
- */
- previewMock?: boolean;
- /**
- * When set, onboarding runs against an already-created company (the "add an
- * agent to an existing company" entry): company creation is skipped and the
- * flow starts at the agent step.
- */
- existingCompany?: { id: string; prefix: string | null };
-}
-
-export function CloudOnboardingFlow({
- onClose,
- initialStep = "start",
- previewMock = false,
- existingCompany,
-}: CloudOnboardingFlowProps) {
- const navigate = useNavigate();
- const flow = useOnboardingFlow(
- existingCompany
- ? { createdCompanyId: existingCompany.id, createdCompanyPrefix: existingCompany.prefix }
- : undefined,
- );
-
- const [step, setStep] = useState(initialStep);
- const [companyName, setCompanyName] = useState("");
- const [mission, setMission] = useState("");
- const [agentRole, setAgentRole] = useState("");
- const [agentName, setAgentName] = useState("");
- const [taskChoice, setTaskChoice] = useState(null);
- const [customTask, setCustomTask] = useState("");
-
- function handleRoleChange(value: string) {
- setAgentRole(value);
- setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
- }
-
- async function handleCreateCompany() {
- if (previewMock) {
- setStep("agent");
- return;
- }
- const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
- if (result) setStep("agent");
- }
-
- async function handleCreateAgent() {
- if (previewMock) {
- setStep("task");
- return;
- }
- const result = await flow.hireLeadAgent({
- agentName: agentName || agentRole,
- adapter: { adapterType: DEFAULT_ADAPTER, model: "", command: "", args: "", url: "" },
- instructions: {
- companyName,
- companyGoal: mission,
- growPath: false,
- growWorkflows: "",
- growPainPoints: "",
- growAutomate: "",
- q1: "",
- q2: "",
- q3: "",
- q4: "",
- },
- // The cloud agent step has no environment probe; keep hire simple.
- requireEnvProbe: false,
- });
- if (result) setStep("task");
- }
-
- async function handleGetStarted() {
- if (!taskChoice) return;
- if (previewMock) {
- window.alert(
- "Preview complete — in the real app this launches the first task and opens your dashboard.",
- );
- return;
- }
- const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
- if (result) {
- onClose?.();
- navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
- }
- }
-
- return (
-
- {step === "start" && setStep("company")} />}
- {step === "company" && (
- setStep("start")}
- onNext={handleCreateCompany}
- loading={flow.loading}
- />
- )}
- {step === "agent" && (
- onClose?.() : () => setStep("company")}
- onNext={handleCreateAgent}
- loading={flow.loading}
- />
- )}
- {step === "task" && (
- setStep("agent")}
- onGetStarted={handleGetStarted}
- loading={flow.loading}
- error={flow.error}
- />
- )}
-
- );
-}
diff --git a/ui/src/components/onboarding/FooterNav.tsx b/ui/src/components/onboarding/FooterNav.tsx
deleted file mode 100644
index 1f5bec15e6..0000000000
--- a/ui/src/components/onboarding/FooterNav.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
-import { Button } from "@/components/ui/button";
-
-/**
- * Shared footer navigation for onboarding step cards: a ghost pill "Back"
- * button (visible on hover) and a primary pill CTA that shows a spinner +
- * loading label while its action runs.
- */
-export function FooterNav({
- onBack,
- primaryLabel,
- primaryDisabled,
- loading,
- loadingLabel,
- onPrimary,
-}: {
- onBack: () => void;
- primaryLabel: string;
- primaryDisabled?: boolean;
- loading?: boolean;
- loadingLabel?: string;
- onPrimary: () => void;
-}) {
- return (
-
- {/* has-[>svg]:pr-4 gives the "Back" text room from the pill's right edge
- (overrides size="sm"'s has-[>svg]:px-2.5 for the right side only). */}
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/GithubStarInterstitial.tsx b/ui/src/components/onboarding/GithubStarInterstitial.tsx
deleted file mode 100644
index 526cc682ac..0000000000
--- a/ui/src/components/onboarding/GithubStarInterstitial.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Github, Star } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
-
-const PAPERCLIP_REPO_URL = "https://github.com/paperclipai/paperclip";
-
-/**
- * Local-flow only: a warm "star us on GitHub" interstitial shown after "Get
- * started", before landing in the app. "Star on GitHub" opens the repo in a new
- * tab and continues; "Not right now" just continues. Both call onContinue,
- * which runs the same completion as the cloud flow (launch task → dashboard).
- */
-export function GithubStarInterstitial({ onContinue }: { onContinue: () => void }) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/LocalOnboardingFlow.tsx b/ui/src/components/onboarding/LocalOnboardingFlow.tsx
deleted file mode 100644
index da9d3666a1..0000000000
--- a/ui/src/components/onboarding/LocalOnboardingFlow.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-// Local onboarding flow — the thin container for the local (self-hosted) app.
-// Reuses the shared step views + shell (OnboardingScaffold) and inserts the
-// local-only pieces: an optional email ask before the numbered steps, a
-// model/adapter step after naming the agent (which runs the env probe on hire),
-// and a "star us on GitHub" interstitial after "Get started".
-//
-// Order: Start → Email → Company(1) → Agent(2) → Adapter(3) → Task(4)
-// → [Get started] → GitHub-star interstitial → dashboard.
-import { useEffect, useRef, useState } from "react";
-import { useNavigate } from "@/lib/router";
-import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
-import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
-import { OnboardingScaffold } from "./OnboardingScaffold";
-import { AUTH_EXIT_DURATION, OnboardingAuthBackdrop } from "./OnboardingAuthBackdrop";
-import { GithubStarInterstitial } from "./GithubStarInterstitial";
-import { StartStep } from "./steps/StartStep";
-import { EmailStep } from "./steps/EmailStep";
-import { CompanyStep } from "./steps/CompanyStep";
-import { AgentStep } from "./steps/AgentStep";
-import { AdapterStep } from "./steps/AdapterStep";
-import { TaskStep } from "./steps/TaskStep";
-
-type Step = "start" | "email" | "company" | "agent" | "adapter" | "task";
-
-const DEFAULT_ADAPTER = "claude_local";
-
-// Numbered steps drive the Stepper position ("Step N of M").
-const NUMBERED: Step[] = ["company", "agent", "adapter", "task"];
-function stepper(s: Step) {
- return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
-}
-
-export interface LocalOnboardingFlowProps {
- onClose?: () => void;
- initialStep?: Step;
- previewMock?: boolean;
-}
-
-export function LocalOnboardingFlow({
- onClose,
- initialStep = "start",
- previewMock = false,
-}: LocalOnboardingFlowProps) {
- const navigate = useNavigate();
- const flow = useOnboardingFlow();
-
- const [step, setStep] = useState(initialStep);
- const [showGithubStar, setShowGithubStar] = useState(false);
- // The welcome screen is rendered by OnboardingAuthBackdrop — the same shell
- // the cloud flow's auth screens use — so leaving it plays the identical
- // hand-off: card lowers + fades while the paperclip fades out, and only once
- // that finishes does the step shell mount. "exiting" holds that gap.
- const [welcomePhase, setWelcomePhase] = useState<"welcome" | "exiting" | "done">(
- initialStep === "start" ? "welcome" : "done",
- );
- const welcomeTimer = useRef(undefined);
-
- useEffect(() => () => window.clearTimeout(welcomeTimer.current), []);
-
- function leaveWelcome() {
- setWelcomePhase("exiting");
- welcomeTimer.current = window.setTimeout(() => {
- setStep("email");
- setWelcomePhase("done");
- }, AUTH_EXIT_DURATION * 1000);
- }
-
- const [email, setEmail] = useState("");
- const [companyName, setCompanyName] = useState("");
- const [mission, setMission] = useState("");
- const [agentRole, setAgentRole] = useState("");
- const [agentName, setAgentName] = useState("");
- const [adapterType, setAdapterType] = useState(DEFAULT_ADAPTER);
- const [taskChoice, setTaskChoice] = useState(null);
- const [customTask, setCustomTask] = useState("");
-
- const adapterInput = { adapterType, model: "", command: "", args: "", url: "" };
-
- // Switching adapters invalidates the previous adapter's environment probe:
- // drop it so the retry re-probes and the step stops showing a verdict about
- // an adapter the user is no longer hiring on.
- function handleAdapterChange(value: string) {
- setAdapterType(value);
- flow.clearAdapterEnvResult();
- }
-
- function handleRoleChange(value: string) {
- setAgentRole(value);
- setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
- }
-
- async function handleCreateCompany() {
- if (previewMock) {
- setStep("agent");
- return;
- }
- const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
- if (result) setStep("agent");
- }
-
- // Adapter step → hire the lead agent on the chosen local adapter, requiring
- // the environment probe to pass (the local flow's key difference from cloud).
- async function handleHireAgent() {
- if (previewMock) {
- setStep("task");
- return;
- }
- const result = await flow.hireLeadAgent({
- agentName: agentName || agentRole,
- adapter: adapterInput,
- instructions: {
- companyName,
- companyGoal: mission,
- growPath: false,
- growWorkflows: "",
- growPainPoints: "",
- growAutomate: "",
- q1: "",
- q2: "",
- q3: "",
- q4: "",
- },
- requireEnvProbe: true,
- });
- if (result) setStep("task");
- }
-
- function handleGetStarted() {
- if (!taskChoice) return;
- setShowGithubStar(true);
- }
-
- // Runs from the interstitial's CTAs — the same completion as the cloud flow.
- async function completeOnboarding() {
- if (!taskChoice) return;
- if (previewMock) {
- window.alert(
- "Preview complete — in the real app this launches the first task and opens your dashboard.",
- );
- return;
- }
- const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
- if (result) {
- onClose?.();
- navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
- }
- }
-
- // Welcome screen: same shell + exit transition as the cloud auth screens.
- // The local flow skips sign-in, so this screen carries the orbiting paperclip.
- if (welcomePhase !== "done") {
- return (
-
-
-
- );
- }
-
- return (
-
- {showGithubStar ? (
-
- ) : (
- <>
- {step === "email" && (
- setStep("company")}
- onSkip={() => setStep("company")}
- />
- )}
- {step === "company" && (
- setStep("email")}
- onNext={handleCreateCompany}
- loading={flow.loading}
- />
- )}
- {step === "agent" && (
- setStep("company")}
- onNext={() => setStep("adapter")}
- primaryLabel="Next"
- />
- )}
- {step === "adapter" && (
- setStep("agent")}
- onNext={handleHireAgent}
- loading={flow.loading}
- />
- )}
- {step === "task" && (
- setStep("adapter")}
- onGetStarted={handleGetStarted}
- loading={flow.loading}
- error={flow.error}
- />
- )}
- >
- )}
-
- );
-}
diff --git a/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx b/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx
deleted file mode 100644
index dfeb4c0002..0000000000
--- a/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { lazy, Suspense, type ReactNode } from "react";
-import { AnimatePresence, motion } from "motion/react";
-import { X } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { STEP_EASE } from "./onboarding-motion";
-
-// three.js is large, so the orbiting-paperclip canvas is code-split: it only
-// downloads when the auth screens actually render.
-const PaperclipOrbit3D = lazy(() =>
- import("./PaperclipOrbit3D").then((m) => ({ default: m.PaperclipOrbit3D })),
-);
-
-/** Duration of the hand-off out of the auth screens (modal + backdrop together). */
-export const AUTH_EXIT_DURATION = 0.35;
-
-/**
- * The orbiting-paperclip layer plus its scrim, as a non-interactive fill layer.
- * Used behind the auth screens (cloud) and behind the welcome screen (local,
- * which skips auth entirely). The scrim keeps the page calm without muting the
- * gradient; panels above it are translucent so the motif reads through.
- */
-export function PaperclipBackdropLayer({ className }: { className?: string }) {
- return (
-
-
-
-
- {/* Very slight darkening scrim — enough to settle the backdrop behind the
- panels without muting the gradient. Applies to every screen that shows
- the paperclip (cloud auth screens + the local welcome screen), since
- both render through this layer. */}
-
-
- );
-}
-
-/**
- * Full-screen shell for the onboarding auth screens (create account / verify
- * code), with the orbiting 3D paperclip behind the card.
- *
- * On completion (`visible` → false) the card settles down and fades while the
- * backdrop fades out at the same time — a quick, subtle hand-off into whatever
- * comes next. Render this around the auth screens and flip `visible` when the
- * user finishes signing up or logging in.
- */
-export function OnboardingAuthBackdrop({
- visible,
- onClose,
- children,
-}: {
- visible: boolean;
- /** Optional dismiss affordance, matching OnboardingScaffold's close button. */
- onClose?: () => void;
- children: ReactNode;
-}) {
- return (
-
- {visible ? (
-
- {/* Orbiting paperclip backdrop — sits behind the card, non-interactive.
- It fades with the container; a dimming overlay keeps the card
- legible over the bright gradient. */}
-
-
-
-
- {onClose ? (
-
- ) : null}
-
- {/* The card lowers slightly and fades on the way out. */}
-
- {children}
-
-
- ) : null}
-
- );
-}
diff --git a/ui/src/components/onboarding/OnboardingAuthScreens.tsx b/ui/src/components/onboarding/OnboardingAuthScreens.tsx
deleted file mode 100644
index eeb06dc3b0..0000000000
--- a/ui/src/components/onboarding/OnboardingAuthScreens.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-// Presentational-only auth screens (create account + email OTP), ported from
-// the prototype onto the repo's design tokens. These are NOT wired to real
-// auth — the app authenticates via better-auth upstream of onboarding. They
-// exist so the full onboarding visual arc can be previewed. See the preview
-// harness (onboarding-preview-main.tsx).
-import { useRef, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { cn } from "@/lib/utils";
-import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
-
-function GoogleMark({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function GithubMark({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function SocialButton({ mark, label }: { mark: React.ReactNode; label: string }) {
- return (
-
- );
-}
-
-/** Create-account screen (visual only). */
-export function AccountScreen({ onContinue }: { onContinue?: () => void }) {
- return (
-
-
-
-
-
-
-
-
- We'll send you a 6-digit code to confirm your email.
-
-
- );
-}
-
-/** Segmented progress bar with "Step N of M" meta. `total` defaults to 3. */
-export function Stepper({ step, total = 3 }: { step: number; total?: number }) {
- return (
-
-
- {Array.from({ length: total }, (_, i) => i + 1).map((s) => (
-
- ))}
-
- );
-}
diff --git a/ui/src/components/onboarding/OnboardingScaffold.tsx b/ui/src/components/onboarding/OnboardingScaffold.tsx
deleted file mode 100644
index 4ef9ceef55..0000000000
--- a/ui/src/components/onboarding/OnboardingScaffold.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { ReactNode } from "react";
-import { AnimatePresence, MotionConfig, motion } from "motion/react";
-import { X } from "lucide-react";
-import { stepMotion } from "./onboarding-motion";
-
-/**
- * Full-screen animated shell shared by both onboarding flows. Owns the close
- * button and the single `AnimatePresence`/keyed `motion.div` crossfade, so both
- * flows animate step transitions identically. `stepKey` must change whenever the
- * rendered step changes (that's what drives the enter/exit).
- */
-export function OnboardingScaffold({
- stepKey,
- onClose,
- children,
-}: {
- stepKey: string;
- onClose?: () => void;
- children: ReactNode;
-}) {
- return (
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/PaperclipOrbit3D.tsx b/ui/src/components/onboarding/PaperclipOrbit3D.tsx
deleted file mode 100644
index 971ef51bfc..0000000000
--- a/ui/src/components/onboarding/PaperclipOrbit3D.tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-// Orbiting 3D paperclip — the animated backdrop behind the onboarding auth
-// screens. Ported from the Paperclip graphic generator's 3D tab
-// (paperclip-gen.vercel.app) with its default settings plus auto-orbit on:
-// the same SVG-derived clip path, tube/cap geometry, "soft" gradient shader,
-// and orbit math, so the motif matches the generator exactly.
-//
-// three.js is heavy, so this module is loaded lazily (see OnboardingBackdrop).
-import { useEffect, useRef } from "react";
-import * as THREE from "three";
-
-// ── Generator defaults (3D tab) ───────────────────────────────────────────
-const TUBE_RADIUS = 0.225;
-const TUBE_SEG = 48; // radial segments
-const PATH_SEG = 300; // segments along the clip path
-const CAPS = true; // hemispherical caps
-const LIGHT_SOFT = 0;
-const LIGHT_ANGLE = 3.87;
-const EMISSIVE = 1;
-const CAM_DIST = 5;
-const FOV = 45;
-/** Auto-orbit speed. 0.5 was the generator default; slowed 25% to 0.375. */
-const ROT_SPEED = 0.375;
-/** Tilt oscillation rate. Slowed 25% alongside ROT_SPEED so the orbit's
- * rotation and tilt stay in the same relationship, just 25% calmer. */
-const TILT_RATE = 0.2625;
-/** Tilt amplitude of the orbit. Pace is set by the sin() frequency below, so
- * raising this swings further without speeding the motion up. */
-const AXIS_TILT = 1.2;
-/** Clip is scaled up 30% from the generator's default framing. */
-const CLIP_UPSCALE = 1.3;
-
-// Colour: instead of the generator's "animate gradient" (which scrolls the
-// gradient ALONG the clip), the gradient stops hold their position and the two
-// stops slowly crossfade through this palette, so the whole clip cycles hue as
-// it orbits. Drawn from the brand agent-gradient stops, limited to the
-// saturated ones — the brand pastels (cream/peach/lavender) read as washed-out
-// grey against the dark backdrop.
-const PALETTE: Array<[string, string]> = [
- ["#4fbcba", "#3aa35c"], // teal → green (the reference comp's colourway)
- ["#3aa35c", "#f2d95f"], // green → yellow
- ["#e3a21a", "#e94b27"], // amber → orange-red
- ["#ee79a1", "#bd7ff0"], // pink → purple
- ["#7eb6e3", "#3355ff"], // light blue → blue
- ["#3355ff", "#cc3388"], // blue → magenta (generator default)
-];
-/** Seconds each palette pair holds before crossfading into the next. */
-const PALETTE_CYCLE_SECONDS = 9;
-/**
- * Static gradient position along the path (no scrolling — "animate gradient" is
- * off). Must stay 0: the shader wraps the gradient with mod(t, 1.0), and any
- * non-zero shift puts that wrap seam partway along the clip, which makes the
- * end caps (whose gradient-t is pinned to the path ends) sample across the seam
- * and read as a different colour from the tube beside them.
- */
-const GRADIENT_SHIFT = 0;
-
-// The clip motif: an SVG path sampled into a Catmull-Rom curve. Scale + center
-// come from the generator so proportions match.
-const CLIP_PATH_D =
- "M5.00146 4.99569V21.005C5.00146 22.1084 5.89689 23.0028 7.00146 23.0028C8.10603 23.0028 9.00147 22.1084 9.00147 21.005L9 4.99569C9 2.78893 7.20914 1 5 1C2.79086 1 1 2.78893 1 4.99569V21.005C1 24.3159 3.68695 27 7.00146 27C10.316 27 13.0029 24.3159 13.0029 21.005L13.0029 4.99569";
-const CLIP_CENTER = { cx: 7, cy: 14 };
-const CLIP_SCALE = 1 / 6.5;
-const CURVE_SAMPLES = 200;
-
-const VERTEX_SHADER = `
- attribute float aPathT;
- attribute float aCapT; // -1.0 for tube verts; explicit gradient-t for cap verts
- varying vec3 vWorldNormal;
- varying float vPathT;
- varying float vCapT;
- void main() {
- vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
- vPathT = aPathT;
- vCapT = aCapT;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
- }
-`;
-
-const FRAGMENT_SHADER = `
- #define MAX_COLORS 8
- uniform vec3 uColors[MAX_COLORS];
- uniform int uNColors;
- uniform float uShift;
- uniform float uSoftness;
- uniform vec3 uLightDir;
- uniform float uEmissive;
- varying vec3 vWorldNormal;
- varying float vPathT;
- varying float vCapT;
-
- vec3 sampleGradAt(float t) {
- float n = float(uNColors);
- float nc = n;
- float pos = clamp(t * (n - 1.0), 0.0, n - 1.0001);
- int idx = int(pos);
- float f = smoothstep(0.0, 1.0, pos - float(idx));
- vec3 cols[MAX_COLORS];
- cols[0]=uColors[0];cols[1]=uColors[1];cols[2]=uColors[2];cols[3]=uColors[3];
- cols[4]=uColors[4];cols[5]=uColors[5];cols[6]=uColors[6];cols[7]=uColors[7];
- int i0 = int(mod(float(idx), nc));
- int i1 = int(mod(float(idx+1), nc));
- return mix(cols[i0], cols[i1], f);
- }
-
- void main() {
- float shiftT = uShift / (2.0 * 3.14159265);
- float rawT = (vCapT >= 0.0) ? vCapT : vPathT;
- float nCols = float(uNColors);
- float scaled = rawT * (nCols - 1.0) / nCols;
- float t = mod(scaled + shiftT, 1.0);
- vec3 baseColor = sampleGradAt(t);
-
- vec3 nor = normalize(vWorldNormal);
- float wrap = mix(0.0, 0.6, uSoftness);
- float NdotL = dot(nor, normalize(uLightDir));
- float diff = smoothstep(0.0, 1.0, clamp((NdotL + wrap) / (1.0 + wrap), 0.0, 1.0));
- float ambient = mix(0.3, 0.6, uSoftness);
- vec3 lit = baseColor * (ambient + diff * (1.0 - ambient));
- lit = mix(lit, baseColor, uEmissive * 0.85);
- lit = pow(max(lit, vec3(0.0)), vec3(1.0 / 2.2));
- gl_FragColor = vec4(lit, 1.0);
- }
-`;
-
-/** Sample the clip SVG path into a centered, scaled Catmull-Rom curve. */
-function buildClipCurve(): THREE.CatmullRomCurve3 {
- const svgNS = "http://www.w3.org/2000/svg";
- const svg = document.createElementNS(svgNS, "svg");
- svg.setAttribute("width", "0");
- svg.setAttribute("height", "0");
- const path = document.createElementNS(svgNS, "path");
- path.setAttribute("d", CLIP_PATH_D);
- svg.appendChild(path);
- document.body.appendChild(svg);
-
- const total = path.getTotalLength();
- const points: THREE.Vector3[] = [];
- for (let i = 0; i <= CURVE_SAMPLES; i++) {
- const p = path.getPointAtLength((i / CURVE_SAMPLES) * total);
- points.push(
- new THREE.Vector3(
- (p.x - CLIP_CENTER.cx) * CLIP_SCALE,
- -(p.y - CLIP_CENTER.cy) * CLIP_SCALE,
- 0,
- ),
- );
- }
- document.body.removeChild(svg);
- return new THREE.CatmullRomCurve3(points, false, "catmullrom", 0.1);
-}
-
-/** Hemispherical end cap oriented along `normal`, tagged with its gradient t. */
-function buildCap(
- center: THREE.Vector3,
- normal: THREE.Vector3,
- radius: number,
- radialSeg: number,
- pathT: number,
-): THREE.BufferGeometry {
- const geo = new THREE.SphereGeometry(
- radius,
- radialSeg,
- Math.ceil(radialSeg / 2),
- 0,
- Math.PI * 2,
- 0,
- Math.PI / 2,
- );
- const count = geo.attributes.position.count;
- geo.setAttribute("aPathT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
- geo.setAttribute("aCapT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
- const up = new THREE.Vector3(0, 1, 0);
- const quat = new THREE.Quaternion().setFromUnitVectors(up, normal.clone().normalize());
- geo.applyMatrix4(new THREE.Matrix4().makeRotationFromQuaternion(quat));
- geo.translate(center.x, center.y, center.z);
- return geo;
-}
-
-export function PaperclipOrbit3D({ className }: { className?: string }) {
- const hostRef = useRef(null);
-
- useEffect(() => {
- const host = hostRef.current;
- if (!host) return;
-
- // Respect reduced motion: render a single static frame instead of orbiting.
- const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
-
- let renderer: THREE.WebGLRenderer;
- try {
- renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
- } catch {
- return; // No WebGL — the backdrop simply stays empty.
- }
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- renderer.setClearColor(0x000000, 0);
- host.appendChild(renderer.domElement);
- renderer.domElement.style.width = "100%";
- renderer.domElement.style.height = "100%";
- renderer.domElement.style.display = "block";
-
- const scene = new THREE.Scene();
- const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100);
-
- // ── Material (generator's "soft" mode) ──
- // Two live stops; the render loop crossfades them through PALETTE.
- const uColors = Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1));
- const paletteColors = PALETTE.map(
- ([a, b]) => [new THREE.Color(a), new THREE.Color(b)] as const,
- );
- const stopA = new THREE.Color();
- const stopB = new THREE.Color();
- // Stops are declared as [A, B, B] (nColors 3) rather than [A, B]: the
- // shader maps path position through `rawT * (n-1)/n`, so with 2 colours the
- // clip would only reach the A→B midpoint. The repeated final stop lets the
- // gradient complete A→B across the clip while keeping t inside [0,1) — no
- // mod() wrap, so the end caps still match the tube.
- const material = new THREE.ShaderMaterial({
- uniforms: {
- uColors: { value: uColors },
- uNColors: { value: 3 },
- uShift: { value: GRADIENT_SHIFT },
- uSoftness: { value: LIGHT_SOFT },
- uLightDir: {
- value: new THREE.Vector3(
- Math.cos(LIGHT_ANGLE) * 0.7,
- 0.8,
- Math.sin(LIGHT_ANGLE) * 0.7,
- ).normalize(),
- },
- uEmissive: { value: EMISSIVE },
- },
- vertexShader: VERTEX_SHADER,
- fragmentShader: FRAGMENT_SHADER,
- side: THREE.DoubleSide,
- });
-
- // ── Geometry: tube along the clip curve + hemispherical caps ──
- const curve = buildClipCurve();
- const group = new THREE.Group();
- const geometries: THREE.BufferGeometry[] = [];
-
- const tube = new THREE.TubeGeometry(curve, PATH_SEG, TUBE_RADIUS, TUBE_SEG, false);
- // TubeGeometry lays out (PATH_SEG+1) rings of (TUBE_SEG+1) verts: aPathT is
- // the ring's position along the path; aCapT -1 marks "not a cap".
- {
- const count = tube.attributes.position.count;
- const pathT = new Float32Array(count);
- const capT = new Float32Array(count).fill(-1);
- const ring = TUBE_SEG + 1;
- for (let i = 0; i < count; i++) pathT[i] = Math.floor(i / ring) / PATH_SEG;
- tube.setAttribute("aPathT", new THREE.BufferAttribute(pathT, 1));
- tube.setAttribute("aCapT", new THREE.BufferAttribute(capT, 1));
- }
- geometries.push(tube);
-
- if (CAPS) {
- const pts = curve.getPoints(PATH_SEG);
- const startNormal = pts[1].clone().sub(pts[0]).normalize().negate();
- const endNormal = pts[pts.length - 1]
- .clone()
- .sub(pts[pts.length - 2])
- .normalize();
- geometries.push(buildCap(pts[0], startNormal, TUBE_RADIUS, TUBE_SEG, 0));
- geometries.push(buildCap(pts[pts.length - 1], endNormal, TUBE_RADIUS, TUBE_SEG, 1));
- }
-
- geometries.forEach((geo) => group.add(new THREE.Mesh(geo, material)));
- group.scale.setScalar(CLIP_UPSCALE);
- scene.add(group);
-
- const target = new THREE.Vector3(0, 0, 0);
-
- function resize() {
- const w = host!.clientWidth;
- const h = host!.clientHeight;
- if (w === 0 || h === 0) return;
- renderer.setSize(w, h, false);
- camera.aspect = w / h;
- camera.updateProjectionMatrix();
- }
- resize();
- const observer = new ResizeObserver(resize);
- observer.observe(host);
-
- // ── Orbit + animated gradient (generator's loop, auto-orbit on) ──
- const start = performance.now();
- let raf = 0;
- function frame() {
- const t = reduceMotion ? 0 : (performance.now() - start) / 1000;
- const theta = t * ROT_SPEED * 0.25;
- const phi = Math.PI / 2 + Math.sin(t * TILT_RATE) * AXIS_TILT;
- camera.position.set(
- target.x + CAM_DIST * Math.sin(phi) * Math.sin(theta),
- target.y + CAM_DIST * Math.cos(phi),
- target.z + CAM_DIST * Math.sin(phi) * Math.cos(theta),
- );
- camera.lookAt(target);
-
- // Slow colour cycle: ease between consecutive palette pairs so the whole
- // clip crossfades hue over time (the gradient itself stays put on the
- // path — the generator's scrolling "animate gradient" is off).
- const pos = (t / PALETTE_CYCLE_SECONDS) % paletteColors.length;
- const idx = Math.floor(pos);
- const raw = pos - idx;
- const f = raw * raw * (3 - 2 * raw); // smoothstep
- const from = paletteColors[idx];
- const to = paletteColors[(idx + 1) % paletteColors.length];
- // lerpHSL rotates hue instead of crossing through grey, so the clip stays
- // saturated mid-transition (plain RGB lerp muddies opposing hues).
- stopA.copy(from[0]).lerpHSL(to[0], f);
- stopB.copy(from[1]).lerpHSL(to[1], f);
- uColors[0].set(stopA.r, stopA.g, stopA.b);
- // Stops 1..7 all hold B (see the [A, B, B] note above).
- for (let i = 1; i < 8; i++) uColors[i].set(stopB.r, stopB.g, stopB.b);
-
- renderer.render(scene, camera);
- if (!reduceMotion) raf = requestAnimationFrame(frame);
- }
- frame();
-
- return () => {
- cancelAnimationFrame(raf);
- observer.disconnect();
- geometries.forEach((g) => g.dispose());
- material.dispose();
- renderer.dispose();
- if (renderer.domElement.parentNode === host) host.removeChild(renderer.domElement);
- };
- }, []);
-
- return ;
-}
-
-export default PaperclipOrbit3D;
diff --git a/ui/src/components/onboarding/onboarding-data.ts b/ui/src/components/onboarding/onboarding-data.ts
deleted file mode 100644
index 92a66d96f4..0000000000
--- a/ui/src/components/onboarding/onboarding-data.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-// Static option data for the onboarding flow, ported from the prototype
-// (paperclip-onboard/src/data.js). Connectors + models are presentational in
-// the first cut; wiring them to real integrations is a later refinement.
-import {
- DEFAULT_TASK_DESCRIPTION,
- DEFAULT_TASK_TITLE,
-} from "@/lib/onboarding-constants";
-
-export const MISSION_CHIPS = [
- "Build a SaaS product",
- "Launch a marketplace",
- "Scale a content business",
-];
-
-export const ROLE_OPTIONS = [
- "Chief of Staff",
- "Chief Technical Officer",
- "Head of Marketing",
- "Researcher",
- "Coder",
- "Designer",
- "Other",
-];
-
-export const ROLE_ACRONYMS: Record = {
- "Chief of Staff": "COS",
- "Chief Technical Officer": "CTO",
- "Head of Marketing": "HOM",
- Researcher: "RES",
- Coder: "CDR",
- Designer: "DES",
- Other: "OTH",
-};
-
-export interface ConnectorOption {
- name: string;
- description: string;
- /** Glyph background color. */
- color: string;
- /** Whether the glyph needs a border (for near-black glyphs). */
- border?: boolean;
- /** Short glyph text. */
- glyph: string;
-}
-
-export const CONNECTORS: ConnectorOption[] = [
- { name: "GitHub", description: "Code, PRs, issues", color: "#0a0a0a", border: true, glyph: "GH" },
- { name: "Google", description: "Gmail, Drive, Calendar", color: "#4285f4", glyph: "G" },
- { name: "Notion", description: "Docs and databases", color: "#0a0a0a", border: true, glyph: "N" },
- { name: "Linear", description: "Issues and projects", color: "#5e6ad2", glyph: "L" },
- { name: "Supabase", description: "Database and auth", color: "#3ecf8e", glyph: "S" },
- { name: "Slack", description: "Team messaging", color: "#611f69", glyph: "#" },
- { name: "Stripe", description: "Payments and billing", color: "#635bff", glyph: "$" },
- { name: "Vercel", description: "Deploys and hosting", color: "#0a0a0a", border: true, glyph: "▲" },
-];
-
-export type FirstTaskChoice = "hiring" | "strategy" | "custom";
-
-/** Build the first-task title/description from the chosen option (shared by both flows). */
-export function firstTaskPayload(
- choice: FirstTaskChoice,
- custom: string,
-): { title: string; description: string } {
- switch (choice) {
- case "hiring":
- return { title: DEFAULT_TASK_TITLE, description: DEFAULT_TASK_DESCRIPTION };
- case "strategy":
- return {
- title: "Write a one-page team strategy",
- description:
- "You are the CEO. Turn the company mission into a one-page strategy: the goals, the bets you're making, and how you'll measure progress.",
- };
- case "custom":
- return {
- title: custom.trim() || "First task",
- description: custom.trim(),
- };
- }
-}
diff --git a/ui/src/components/onboarding/onboarding-motion.ts b/ui/src/components/onboarding/onboarding-motion.ts
deleted file mode 100644
index 706e96b111..0000000000
--- a/ui/src/components/onboarding/onboarding-motion.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// Shared motion constants for the onboarding flows (cloud + local). Extracted
-// from the original OnboardingFlow so both flow containers and the shared step
-// views animate identically. Reduced-motion is honored globally via
-// in OnboardingScaffold.
-
-/** Step crossfade easing (also reused by in-step reveals). */
-export const STEP_EASE = [0.16, 1, 0.3, 1] as const;
-
-/** Per-step enter/exit crossfade used by the scaffold's keyed motion.div. */
-export const stepMotion = {
- initial: { opacity: 0, y: 8 },
- animate: { opacity: 1, y: 0 },
- exit: { opacity: 0, y: -8 },
- transition: { duration: 0.28, ease: STEP_EASE },
-};
-
-// Agent-step dashed-pill entrance: fades + scales up from 50% from its center
-// (no y offset, which would bias growth upward), with a spring bounce on
-// landing.
-export const CAPSULE_ENTER_DURATION = 1.0;
-export const capsuleMotion = {
- initial: { opacity: 0, scale: 0.5 },
- animate: { opacity: 1, scale: 1 },
- transition: { type: "spring" as const, duration: CAPSULE_ENTER_DURATION, bounce: 0.4 },
-};
-
-// Agent-step name/role reveal: the capsule slide-up (height growth) and the
-// label fade share this duration; the fade is staggered by 25% of it.
-export const PREVIEW_REVEAL_DURATION = 0.45;
-
-// Step 2 → 3 capsule hand-off: on "Create" the capsule eases out (scale to
-// 50%, fade to 0) with the exiting step, then resurfaces on the task step —
-// scaling back to 100% on a spring so it lands into place, fade eased to match.
-// Exit duration mirrors the step transition so they travel together.
-export const capsuleHandoffExit = {
- scale: 0.5,
- opacity: 0,
- transition: { duration: 0.28, ease: STEP_EASE },
-};
-export const capsuleHeroMotion = {
- initial: { scale: 0.5, opacity: 0 },
- animate: { scale: 1, opacity: 1 },
- transition: {
- // Softer spring = a slower, more noticeable scale-up that still lands with
- // a natural settle; fade lengthened to travel with it.
- scale: { type: "spring" as const, stiffness: 150, damping: 16 },
- opacity: { duration: 0.55, ease: STEP_EASE },
- },
-};
diff --git a/ui/src/components/onboarding/steps/AdapterStep.tsx b/ui/src/components/onboarding/steps/AdapterStep.tsx
deleted file mode 100644
index 8164f4bdba..0000000000
--- a/ui/src/components/onboarding/steps/AdapterStep.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { useMemo } from "react";
-import { motion } from "motion/react";
-import { cn } from "@/lib/utils";
-import { listUIAdapters } from "@/adapters";
-import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
-import { isVisualAdapterChoice } from "@/adapters/metadata";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { AgentCapsule } from "../../AgentCapsule";
-import { OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
-import { FooterNav } from "../FooterNav";
-import { AgentPreview } from "../AgentPreview";
-import { capsuleHandoffExit, capsuleHeroMotion } from "../onboarding-motion";
-
-const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
-
-function visualAdapters() {
- return listUIAdapters()
- .filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && isVisualAdapterChoice(a.type))
- .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }))
- .filter((a) => !a.comingSoon);
-}
-
-/**
- * Local-flow only: pick the model/adapter the first agent runs on. Recommended
- * options are surfaced as cards; the rest are grouped in an "Additional models"
- * dropdown. Selecting from either sets the same adapterType.
- */
-export function AdapterStep({
- adapterType,
- onAdapterChange,
- agentName,
- agentRole,
- onBack,
- onNext,
- loading,
- step,
- total,
-}: {
- adapterType: string;
- onAdapterChange: (type: string) => void;
- agentName: string;
- agentRole: string;
- onBack: () => void;
- onNext: () => void;
- loading?: boolean;
- step: number;
- total?: number;
-}) {
- const { recommended, additional } = useMemo(() => {
- const all = visualAdapters();
- return {
- recommended: all.filter((a) => a.recommended),
- // Top handful of other models beyond the recommended ones.
- additional: all.filter((a) => !a.recommended).slice(0, 5),
- };
- }, []);
-
- // The dropdown reflects the selection only when it isn't one of the cards.
- const additionalValue = additional.some((a) => a.type === adapterType) ? adapterType : undefined;
-
- return (
-
-
-
- {/* Carried-over outlined capsule from the agent step: enters with the
- same hero scale/fade as the task pill and hands off (scale down) on
- exit, but stays "configured" (outlined) — no coming-alive here. */}
-
-
-
- We'll never use this for marketing or share it with third parties — it's only for
- the occasional product update.
-
-
-
-
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/steps/StartStep.tsx b/ui/src/components/onboarding/steps/StartStep.tsx
deleted file mode 100644
index e0dcbdeab9..0000000000
--- a/ui/src/components/onboarding/steps/StartStep.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Mail, PlusCircle } from "lucide-react";
-import { cn } from "@/lib/utils";
-
-/**
- * Welcome / choose-a-path screen (unnumbered). "Set up" advances the flow (the
- * container decides the next step); "Join an existing team" is stubbed.
- *
- * Cards are translucent so the orbiting-paperclip backdrop reads through them
- * (local flow, which skips the auth screens and leads with this screen).
- */
-export function StartStep({ onSetup }: { onSetup: () => void }) {
- return (
-
-
- Welcome to Paperclip!
-
-
- }
- title="Set up Paperclip for your company or team"
- description="Create a new organization, build your first agent, and assign its first task."
- onClick={onSetup}
- />
- }
- title="Join an existing company or team"
- description="Have an invite? Enter your team's join code to come aboard."
- onClick={() => {}}
- disabled
- />
-
- Where should we start? {agentLabel} is ready for instructions.>}
- center
- />
- {/* The reveal sits OUTSIDE the gap-3 flex and owns its own top spacing
- (pt-3), so collapsing to height 0 leaves no phantom flex gap to snap
- away on unmount — the collapse mirrors the expand exactly. overflow
- is hidden only while animating and visible at rest so the textarea's
- focus ring isn't clipped. */}
-
-
- }
- title="Create a hiring plan"
- description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
- selected={taskChoice === "hiring"}
- onClick={() => onSelectChoice("hiring")}
- />
- }
- title="Write a team strategy doc"
- description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
- selected={taskChoice === "strategy"}
- onClick={() => onSelectChoice("strategy")}
- />
- }
- title="Write your own task…"
- description="Describe anything else you want done first."
- selected={taskChoice === "custom"}
- onClick={() => onSelectChoice("custom")}
- />
-
-
- {taskChoice === "custom" && (
-
-
-
-
- )}
-
-
- {error ?
{error}
: null}
-
-
-
- );
-}
diff --git a/ui/src/hooks/useOnboardingFlow.test.tsx b/ui/src/hooks/useOnboardingFlow.test.tsx
deleted file mode 100644
index b51a90c409..0000000000
--- a/ui/src/hooks/useOnboardingFlow.test.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react";
-import { flushSync } from "react-dom";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-// The hook only needs a company id and the two agent endpoints for these cases;
-// everything else is mocked so the probe-cache logic is exercised in isolation.
-const testEnvironment = vi.hoisted(() => vi.fn());
-const hire = vi.hoisted(() => vi.fn());
-
-vi.mock("@tanstack/react-query", () => ({
- useQueryClient: () => ({ invalidateQueries: vi.fn() }),
-}));
-vi.mock("@/context/CompanyContext", () => ({
- useCompany: () => ({ setSelectedCompanyId: vi.fn() }),
-}));
-vi.mock("@/api/agents", () => ({
- agentsApi: {
- testEnvironment,
- hire,
- instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })),
- saveInstructionsFile: vi.fn(async () => undefined),
- update: vi.fn(async () => undefined),
- },
-}));
-vi.mock("@/api/companies", () => ({ companiesApi: { create: vi.fn() } }));
-vi.mock("@/api/goals", () => ({ goalsApi: { create: vi.fn() } }));
-vi.mock("@/api/approvals", () => ({ approvalsApi: { approve: vi.fn() } }));
-vi.mock("@/api/issues", () => ({ issuesApi: { create: vi.fn() } }));
-vi.mock("@/api/projects", () => ({ projectsApi: { create: vi.fn(), list: vi.fn() } }));
-
-import { useOnboardingFlow, type OnboardingFlow } from "./useOnboardingFlow";
-
-const INSTRUCTIONS = {
- companyName: "Acme",
- companyGoal: "Build things",
- growPath: false,
- growWorkflows: "",
- growPainPoints: "",
- growAutomate: "",
- q1: "",
- q2: "",
- q3: "",
- q4: "",
-};
-
-function adapter(adapterType: string) {
- return { adapterType, model: "", command: "", args: "", url: "" };
-}
-
-describe("useOnboardingFlow adapter environment probe", () => {
- let container: HTMLDivElement;
- let root: Root | null = null;
- let flow: OnboardingFlow;
-
- function Probe() {
- flow = useOnboardingFlow({ createdCompanyId: "company-1" });
- return null;
- }
-
- beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
- flushSync(() => {
- root!.render();
- });
- testEnvironment.mockResolvedValue({ status: "pass" });
- hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
- });
-
- afterEach(() => {
- flushSync(() => {
- root?.unmount();
- });
- root = null;
- container.remove();
- vi.clearAllMocks();
- });
-
- it("re-probes when the adapter changed since the cached probe ran", async () => {
- // The local flow lets the user pick a different adapter and retry after a
- // failed hire. The first adapter's verdict must not stand in for the second.
- await act(async () => {
- await flow.runAdapterEnvironmentTest(adapter("claude_local"));
- });
- expect(testEnvironment).toHaveBeenCalledTimes(1);
-
- hire.mockRejectedValueOnce(new Error("hire failed"));
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- // Same adapter — the cached probe is reused rather than re-run.
- expect(testEnvironment).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("codex_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- expect(testEnvironment).toHaveBeenCalledTimes(2);
- expect(testEnvironment.mock.calls[1][1]).toBe("codex_local");
- // The probed config and the hired config are the same object of record.
- expect(hire.mock.calls[1][1].adapterConfig).toEqual(
- testEnvironment.mock.calls[1][2].adapterConfig,
- );
- });
-
- it("probes when no cached result exists", async () => {
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- expect(testEnvironment).toHaveBeenCalledTimes(1);
- });
-
- it("clearAdapterEnvResult forces the next hire to re-probe", async () => {
- await act(async () => {
- await flow.runAdapterEnvironmentTest(adapter("claude_local"));
- });
- act(() => {
- flow.clearAdapterEnvResult();
- });
- expect(flow.adapterEnvResult).toBeNull();
-
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- expect(testEnvironment).toHaveBeenCalledTimes(2);
- });
-
- it("does not probe at all when requireEnvProbe is false (cloud flow)", async () => {
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: false,
- });
- });
- expect(testEnvironment).not.toHaveBeenCalled();
- });
-});
diff --git a/ui/src/hooks/useOnboardingFlow.ts b/ui/src/hooks/useOnboardingFlow.ts
deleted file mode 100644
index 189692a82a..0000000000
--- a/ui/src/hooks/useOnboardingFlow.ts
+++ /dev/null
@@ -1,483 +0,0 @@
-// Backend orchestration for onboarding, extracted from OnboardingWizard so a
-// new presentational onboarding flow can reuse the exact company/goal/agent/
-// issue creation logic without duplicating API calls or query invalidation.
-//
-// This hook owns the "created entity" ids and the adapter-environment probe
-// state; it does NOT own user-entered form state (company name, mission, agent
-// name, adapter selection) — callers pass those into the action functions. It
-// also does not navigate: launchFirstTask returns the target company prefix and
-// lets the caller route, keeping routing a UI concern.
-import { useState } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
-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 { issuesApi } from "@/api/issues";
-import { projectsApi } from "@/api/projects";
-import { queryKeys } from "@/lib/queryKeys";
-import { parseOnboardingGoalInput } from "@/lib/onboarding-goal";
-import { composeCeoInstructions } from "@/lib/ceo-instructions";
-import { buildNewAgentRuntimeConfig } from "@/lib/new-agent-runtime-config";
-import {
- buildOnboardingIssuePayload,
- buildOnboardingProjectPayload,
- selectDefaultCompanyGoalId,
- selectReusableOnboardingProject,
-} from "@/lib/onboarding-launch";
-import {
- buildOnboardingAdapterConfig,
- type OnboardingAdapterConfigInput,
-} from "@/lib/onboarding-adapter-config";
-import {
- DEFAULT_TASK_DESCRIPTION,
- DEFAULT_TASK_TITLE,
-} from "@/lib/onboarding-constants";
-
-/** Adapter selection inputs shared by the env probe and the hire action. */
-export type AdapterInput = Omit;
-
-/** Context used to seed the lead agent's instructions file. */
-export interface OnboardingInstructionsContext {
- companyName: string;
- companyGoal: string;
- growPath: boolean;
- growWorkflows: string;
- growPainPoints: string;
- growAutomate: string;
- q1: string;
- q2: string;
- q3: string;
- q4: string;
-}
-
-export interface HireLeadAgentInput {
- agentName: string;
- adapter: AdapterInput;
- instructions: OnboardingInstructionsContext;
- /**
- * When true (local adapters), require a successful adapter-environment probe
- * before hiring. Mirrors the wizard: hire is aborted only if the probe cannot
- * run at all (returns null), not if it returns a fail status.
- */
- requireEnvProbe: boolean;
-}
-
-export interface CreatedOnboardingEntities {
- createdCompanyId: string | null;
- createdCompanyPrefix: string | null;
- createdAgentId: string | null;
- createdCompanyGoalId: string | null;
- createdProjectId: string | null;
- createdIssueRef: string | null;
-}
-
-export function useOnboardingFlow(initial?: Partial) {
- const queryClient = useQueryClient();
- const { setSelectedCompanyId } = useCompany();
-
- const [createdCompanyId, setCreatedCompanyId] = useState(
- initial?.createdCompanyId ?? null,
- );
- const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState(
- initial?.createdCompanyPrefix ?? null,
- );
- const [createdAgentId, setCreatedAgentId] = useState(
- initial?.createdAgentId ?? null,
- );
- const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState(
- initial?.createdCompanyGoalId ?? null,
- );
- const [createdProjectId, setCreatedProjectId] = useState(
- initial?.createdProjectId ?? null,
- );
- const [createdIssueRef, setCreatedIssueRef] = useState(
- initial?.createdIssueRef ?? null,
- );
-
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- 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);
- /**
- * Identifies the adapter selection that produced `adapterEnvResult`. The local
- * flow lets the user pick a different adapter and retry after a failed hire,
- * so a cached probe result is only evidence about the adapter it actually
- * ran against — see `hireLeadAgent`.
- */
- const [adapterEnvProbedKey, setAdapterEnvProbedKey] = useState(null);
-
- function buildAdapterConfig(adapter: AdapterInput): Record {
- return buildOnboardingAdapterConfig({ ...adapter, forceUnsetAnthropicApiKey });
- }
-
- /**
- * Stable identity for a probe: the adapter type plus the exact config posted
- * to the environment-test endpoint. `buildOnboardingAdapterConfig` builds its
- * object in a fixed key order for a given adapter, so `JSON.stringify` is
- * stable across calls.
- */
- function adapterProbeKey(
- adapterType: string,
- adapterConfig: Record,
- ): string {
- return JSON.stringify([adapterType, adapterConfig]);
- }
-
- /**
- * Forget any cached probe result. Callers invoke this when the user changes
- * the adapter selection, so neither the UI nor `hireLeadAgent` keeps showing
- * or trusting a verdict about the adapter that is no longer selected.
- */
- function clearAdapterEnvResult(): void {
- setAdapterEnvResult(null);
- setAdapterEnvError(null);
- setAdapterEnvProbedKey(null);
- }
-
- /**
- * Create the company + its company-level goal from the mission. Guarded so
- * calling it again after a company exists is a no-op that returns the
- * already-created ids.
- */
- async function createCompanyAndGoal(input: {
- companyName: string;
- companyGoal: string;
- }): Promise<{ companyId: string; companyPrefix: string; goalId: string | null } | null> {
- if (createdCompanyId) {
- return {
- companyId: createdCompanyId,
- companyPrefix: createdCompanyPrefix ?? "",
- goalId: createdCompanyGoalId,
- };
- }
- setLoading(true);
- setError(null);
- try {
- const company = await companiesApi.create({ name: input.companyName.trim() });
- setCreatedCompanyId(company.id);
- setCreatedCompanyPrefix(company.issuePrefix);
- setSelectedCompanyId(company.id);
- queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
-
- const parsedGoal = parseOnboardingGoalInput(input.companyGoal);
- const goal = await goalsApi.create(company.id, {
- title: parsedGoal.title,
- ...(parsedGoal.description ? { description: parsedGoal.description } : {}),
- level: "company",
- status: "active",
- });
- setCreatedCompanyGoalId(goal.id);
- queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(company.id) });
-
- return { companyId: company.id, companyPrefix: company.issuePrefix, goalId: goal.id };
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to create company");
- return null;
- } finally {
- setLoading(false);
- }
- }
-
- async function runAdapterEnvironmentTest(
- adapter: AdapterInput,
- adapterConfigOverride?: Record,
- ): Promise {
- if (!createdCompanyId) {
- setAdapterEnvError(
- "Create or select a company before testing adapter environment.",
- );
- return null;
- }
- setAdapterEnvLoading(true);
- setAdapterEnvError(null);
- const adapterConfig = adapterConfigOverride ?? buildAdapterConfig(adapter);
- try {
- const result = await agentsApi.testEnvironment(createdCompanyId, adapter.adapterType, {
- adapterConfig,
- });
- setAdapterEnvResult(result);
- setAdapterEnvProbedKey(adapterProbeKey(adapter.adapterType, adapterConfig));
- return result;
- } catch (err) {
- setAdapterEnvError(
- err instanceof Error ? err.message : "Adapter environment test failed",
- );
- setAdapterEnvResult(null);
- setAdapterEnvProbedKey(null);
- return null;
- } finally {
- setAdapterEnvLoading(false);
- }
- }
-
- /**
- * Clear ANTHROPIC_API_KEY in the adapter config and re-probe. Mirrors the
- * wizard's remediation when a stray key overrides the Claude subscription.
- */
- async function unsetAnthropicApiKeyAndRetry(adapter: AdapterInput): Promise {
- if (!createdCompanyId || unsetAnthropicLoading) return;
- setUnsetAnthropicLoading(true);
- setError(null);
- setAdapterEnvError(null);
- setForceUnsetAnthropicApiKey(true);
-
- const configWithUnset = (() => {
- const config = buildAdapterConfig(adapter);
- 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(adapter, 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);
- }
- }
-
- /**
- * Hire the lead agent (role: ceo), auto-approve any board approval, and seed
- * its instructions file. Guarded so a second call after an agent exists is a
- * no-op. Returns null on failure (error is set) or when a required env probe
- * cannot run.
- */
- async function hireLeadAgent(
- input: HireLeadAgentInput,
- ): Promise<{ agentId: string } | null> {
- if (!createdCompanyId) return null;
- if (createdAgentId) return { agentId: createdAgentId };
-
- setLoading(true);
- setError(null);
- const adapterConfig = buildAdapterConfig(input.adapter);
- try {
- if (input.requireEnvProbe) {
- // Reuse the cached probe only when it ran against this exact adapter
- // selection. After a failed hire the user can go back and pick a
- // different adapter, and the previous adapter's verdict says nothing
- // about the new one.
- const cacheHit =
- adapterEnvResult !== null &&
- adapterEnvProbedKey === adapterProbeKey(input.adapter.adapterType, adapterConfig);
- const result = cacheHit
- ? adapterEnvResult
- : await runAdapterEnvironmentTest(input.adapter, adapterConfig);
- if (!result) return null;
- }
-
- const hire = await agentsApi.hire(createdCompanyId, {
- name: input.agentName.trim(),
- role: "ceo",
- adapterType: input.adapter.adapterType,
- adapterConfig,
- 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 instructions file so the agent always has company
- // context + a hiring-plan output rule. Non-fatal on failure.
- try {
- const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId);
- await agentsApi.saveInstructionsFile(
- agent.id,
- {
- path: bundle.entryFile,
- content: composeCeoInstructions({
- companyName: input.instructions.companyName,
- companyGoal: input.instructions.companyGoal,
- growPath: input.instructions.growPath,
- growWorkflows: input.instructions.growWorkflows,
- growPainPoints: input.instructions.growPainPoints,
- growAutomate: input.instructions.growAutomate,
- q1: input.instructions.q1,
- q2: input.instructions.q2,
- q3: input.instructions.q3,
- q4: input.instructions.q4,
- }),
- },
- createdCompanyId,
- );
- } catch (err) {
- console.warn("Failed to seed CEO instructions:", err);
- }
-
- return { agentId: agent.id };
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to create agent");
- return null;
- } finally {
- setLoading(false);
- }
- }
-
- /**
- * Ensure the onboarding goal + project exist, create the first task assigned
- * to the lead agent, select the company, and return the target company prefix
- * for the caller to navigate to. Idempotent across the goal/project/issue it
- * creates. Requires a created company + agent.
- */
- async function launchFirstTask(input?: {
- title?: string;
- description?: string;
- }): Promise<{ companyId: string; companyPrefix: string | null } | null> {
- if (!createdCompanyId || !createdAgentId) {
- setError(
- "Onboarding state is incomplete. Please restart onboarding and try again.",
- );
- return null;
- }
- setLoading(true);
- setError(null);
- try {
- let goalId = createdCompanyGoalId;
- if (!goalId) {
- const goals = await goalsApi.list(createdCompanyId);
- goalId = selectDefaultCompanyGoalId(goals);
- setCreatedCompanyGoalId(goalId);
- }
-
- let projectId = createdProjectId;
- if (!projectId) {
- const projects = await projectsApi.list(createdCompanyId);
- const existingOnboardingProject = selectReusableOnboardingProject(projects);
- if (existingOnboardingProject) {
- projectId = existingOnboardingProject.id;
- } else {
- const project = await projectsApi.create(
- createdCompanyId,
- buildOnboardingProjectPayload(goalId),
- );
- projectId = project.id;
- queryClient.invalidateQueries({
- queryKey: queryKeys.projects.list(createdCompanyId),
- });
- }
- setCreatedProjectId(projectId);
- }
-
- if (!createdIssueRef) {
- const issue = await issuesApi.create(
- createdCompanyId,
- buildOnboardingIssuePayload({
- title: input?.title ?? DEFAULT_TASK_TITLE,
- description: input?.description ?? DEFAULT_TASK_DESCRIPTION,
- assigneeAgentId: createdAgentId,
- projectId,
- goalId,
- }),
- );
- setCreatedIssueRef(issue.identifier ?? issue.id);
- queryClient.invalidateQueries({
- queryKey: queryKeys.issues.list(createdCompanyId),
- });
- }
-
- setSelectedCompanyId(createdCompanyId);
- return { companyId: createdCompanyId, companyPrefix: createdCompanyPrefix };
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to launch first task");
- return null;
- } finally {
- setLoading(false);
- }
- }
-
- function setCreatedCompany(company: { id: string; prefix: string | null }) {
- setCreatedCompanyId(company.id);
- setCreatedCompanyPrefix(company.prefix);
- }
-
- function reset() {
- setCreatedCompanyId(null);
- setCreatedCompanyPrefix(null);
- setCreatedAgentId(null);
- setCreatedCompanyGoalId(null);
- setCreatedProjectId(null);
- setCreatedIssueRef(null);
- setLoading(false);
- setError(null);
- setAdapterEnvResult(null);
- setAdapterEnvError(null);
- setAdapterEnvLoading(false);
- setAdapterEnvProbedKey(null);
- setForceUnsetAnthropicApiKey(false);
- setUnsetAnthropicLoading(false);
- }
-
- return {
- // created entity state
- createdCompanyId,
- createdCompanyPrefix,
- createdAgentId,
- createdCompanyGoalId,
- createdProjectId,
- createdIssueRef,
- setCreatedCompany,
- setCreatedCompanyPrefix,
-
- // status
- loading,
- error,
- setError,
-
- // adapter environment probe
- adapterEnvResult,
- adapterEnvError,
- adapterEnvLoading,
- forceUnsetAnthropicApiKey,
- unsetAnthropicLoading,
- buildAdapterConfig,
- runAdapterEnvironmentTest,
- clearAdapterEnvResult,
- unsetAnthropicApiKeyAndRetry,
-
- // actions
- createCompanyAndGoal,
- hireLeadAgent,
- launchFirstTask,
- reset,
- };
-}
-
-export type OnboardingFlow = ReturnType;
diff --git a/ui/src/index.css b/ui/src/index.css
index ca5dd2d384..3b4b143346 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -993,57 +993,38 @@
/* Agent capsule motif (PAP-119) — "the capsule is the agent".
Three states realized in the AgentCapsule component:
- - slot: static dashed outline (an empty agent slot)
+ - slot: dashed outline gently pulsing (an empty agent slot)
- configured: solid stroke (named/model picked, not yet live)
- - online: brand agent-gradient fill radiating outward from the center
- + online-pulse (the only pulsing state)
- prefers-reduced-motion skips the fill + pulses and renders the final
- state. */
-/* Radial outward reveal of the (linear-gradient) fill: a centered circular
- clip grows past the half-diagonal (~71%) so it covers the whole capsule. */
-@keyframes agent-cap-fill-radial {
- 0% { clip-path: circle(0% at 50% 50%); }
- 100% { clip-path: circle(75% at 50% 50%); }
+ - online: brand agent-gradient liquid rises to fill + green online-pulse
+ prefers-reduced-motion skips the liquid rise + pulses and renders the
+ final state. */
+@keyframes agent-cap-slot-pulse {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+}
+
+@keyframes agent-cap-rise {
+ 0% { height: 0; }
+ 100% { height: 100%; }
}
@keyframes agent-cap-online-pulse {
0%, 100% {
- box-shadow:
- 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent),
- 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
+ box-shadow: 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
transform: scale(1);
}
50% {
- box-shadow:
- 0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent),
- 0 0 0 14px color-mix(in oklab, #22c55e 7%, transparent);
+ box-shadow: 0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent);
transform: scale(1.03);
}
}
+.agent-cap-slot {
+ animation: agent-cap-slot-pulse 1.6s ease-in-out infinite;
+}
+
.agent-cap-liquid {
- /* Dramatic ease-in-out: the radial fill eases in, rushes, and eases out. */
- animation: agent-cap-fill-radial var(--agent-cap-reveal-duration, 1.4s)
- cubic-bezier(0.83, 0, 0.17, 1) forwards;
-}
-
-/* Online carryover: the solid white outline begins fading as soon as the fill
- starts so the two overlap toward the start (outline gone by the fill's
- midpoint). Shares the reveal duration so an onboarding-scoped override slows
- both together. */
-@keyframes agent-cap-stroke-carryover {
- 0% {
- opacity: 1;
- }
- 50%,
- 100% {
- opacity: 0;
- }
-}
-
-.agent-cap-stroke-carryover {
- animation: agent-cap-stroke-carryover var(--agent-cap-reveal-duration, 1.4s)
- linear forwards;
+ animation: agent-cap-rise 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.agent-cap-online {
@@ -1054,15 +1035,11 @@
Same breathing ring as the green pulse, recoloured to brand blue (#2563eb). */
@keyframes agent-cap-online-pulse-blue {
0%, 100% {
- box-shadow:
- 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent),
- 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
+ box-shadow: 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
transform: scale(1);
}
50% {
- box-shadow:
- 0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent),
- 0 0 0 14px color-mix(in oklab, #2563eb 9%, transparent);
+ box-shadow: 0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent);
transform: scale(1.03);
}
}
@@ -1078,6 +1055,7 @@
}
@media (prefers-reduced-motion: reduce) {
+ .agent-cap-slot,
.agent-cap-online,
.agent-cap-online-blue {
animation: none;
@@ -1087,11 +1065,7 @@
}
.agent-cap-liquid {
animation: none;
- clip-path: none;
- }
- .agent-cap-stroke-carryover {
- animation: none;
- opacity: 0;
+ height: 100%;
}
}
@@ -2350,8 +2324,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
* allow ui/src/pages/RunTranscriptUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
* allow ui/src/pages/InviteUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
* allow ui/src/pages/IssueChatUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
- * allow ui/src/components/onboarding/OnboardingAuthScreens.tsx — Google/GitHub official brand-mark SVG fills (third-party logo colors); brand identity values, not themeable roles
- * allow ui/src/components/onboarding/onboarding-data.ts — connector glyph colors are third-party brand hexes (GitHub/Google/Notion/Linear/…) rendered as logo-tile backgrounds; brand identity values, not themeable roles
* allow ui/src/pages/SystemNoticeUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
Test-fixture hex-literal policy (Batch 4, resolving TOKEN-AUDIT.md section
diff --git a/ui/src/lib/onboarding-adapter-config.test.ts b/ui/src/lib/onboarding-adapter-config.test.ts
deleted file mode 100644
index 91cb12fd35..0000000000
--- a/ui/src/lib/onboarding-adapter-config.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { buildOnboardingAdapterConfig } from "./onboarding-adapter-config";
-
-const baseInput = {
- model: "",
- command: "",
- args: "",
- url: "",
-};
-
-describe("buildOnboardingAdapterConfig", () => {
- it("skips permissions for claude_local", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "claude_local",
- forceUnsetAnthropicApiKey: false,
- });
- expect(config.dangerouslySkipPermissions).toBe(true);
- });
-
- it("forces ANTHROPIC_API_KEY empty for claude_local when requested", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "claude_local",
- forceUnsetAnthropicApiKey: true,
- });
- const env = config.env as Record;
- expect(env.ANTHROPIC_API_KEY).toEqual({ type: "plain", value: "" });
- });
-
- it("does not force ANTHROPIC_API_KEY when the flag is off", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "claude_local",
- forceUnsetAnthropicApiKey: false,
- });
- const env = config.env as Record | undefined;
- expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
- });
-
- it("only applies the ANTHROPIC_API_KEY override to claude_local", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "codex_local",
- forceUnsetAnthropicApiKey: true,
- });
- const env = config.env as Record | undefined;
- expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
- });
-});
diff --git a/ui/src/lib/onboarding-adapter-config.ts b/ui/src/lib/onboarding-adapter-config.ts
deleted file mode 100644
index 9ceb80f8b4..0000000000
--- a/ui/src/lib/onboarding-adapter-config.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-// Pure adapter-config builder for onboarding, extracted verbatim from
-// OnboardingWizard.buildAdapterConfig so the new onboarding flow and the
-// useOnboardingFlow hook can build an agent adapter config from user inputs
-// without duplicating the branch logic.
-import { getUIAdapter } from "@/adapters";
-import { defaultCreateValues } from "@/components/agent-config-defaults";
-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 } from "@paperclipai/adapter-opencode-local";
-
-export interface OnboardingAdapterConfigInput {
- adapterType: string;
- model: string;
- command: string;
- args: string;
- url: string;
- /**
- * When true and the adapter is claude_local, force ANTHROPIC_API_KEY to an
- * empty plain value so a subscription login is used instead of a stray env
- * key that overrides it.
- */
- forceUnsetAnthropicApiKey: boolean;
-}
-
-export function buildOnboardingAdapterConfig(
- input: OnboardingAdapterConfigInput,
-): Record {
- const { adapterType, model, command, args, url, forceUnsetAnthropicApiKey } = input;
- 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;
-}
diff --git a/ui/src/lib/onboarding-constants.ts b/ui/src/lib/onboarding-constants.ts
deleted file mode 100644
index 61b44a7864..0000000000
--- a/ui/src/lib/onboarding-constants.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-// Shared onboarding constants, extracted from OnboardingWizard so the new
-// presentational onboarding flow and the useOnboardingFlow hook share one
-// source of truth for storage keys, default task copy, and error messages.
-
-export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
-
-export const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
-
-export const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
-
-- hire a founding engineer
-- write a hiring plan
-- break the roadmap into concrete tasks and start delegating work`;
-
-export const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
- "Onboarding state is incomplete. Please restart onboarding and try again.";
diff --git a/ui/src/onboarding-preview-main.tsx b/ui/src/onboarding-preview-main.tsx
deleted file mode 100644
index f50add414d..0000000000
--- a/ui/src/onboarding-preview-main.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-// Standalone preview harness for the onboarding flows (no app shell / left nav),
-// modeled on the repo's thread-components harness.
-//
-// - `?flow=cloud` (default) renders CloudOnboardingFlow; `?flow=local` renders
-// LocalOnboardingFlow. Test both side-by-side in two tabs.
-// - The cloud flow walks the full arc (account → OTP → welcome …). The LOCAL
-// flow has no sign-in at all — it opens on its welcome screen, which carries
-// the orbiting-paperclip backdrop the auth screens would have shown.
-// - `?step=` deep-links straight to one screen. Cloud: account | otp |
-// start | company | agent | task. Local: start | email | company | agent |
-// adapter | task.
-//
-// SAFETY: `previewMock` makes every wired CTA skip its backend call, so the
-// flow is clickable end-to-end with no database writes. This harness is for
-// visual review / sharing only.
-import { StrictMode, useEffect, useRef, useState } from "react";
-import { createRoot } from "react-dom/client";
-import { MemoryRouter } from "@/lib/router";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { TooltipProvider } from "@/components/ui/tooltip";
-import { ThemeProvider } from "./context/ThemeContext";
-import { CompanyProvider } from "./context/CompanyContext";
-import { CloudOnboardingFlow } from "./components/onboarding/CloudOnboardingFlow";
-import { LocalOnboardingFlow } from "./components/onboarding/LocalOnboardingFlow";
-import { AccountScreen, OtpScreen } from "./components/onboarding/OnboardingAuthScreens";
-import {
- AUTH_EXIT_DURATION,
- OnboardingAuthBackdrop,
-} from "./components/onboarding/OnboardingAuthBackdrop";
-import "./index.css";
-
-// "exiting" holds the gap between the auth screens fading out and the flow
-// mounting, so the paperclip reaches 0% before the next screen appears.
-type Phase = "account" | "otp" | "exiting" | "flow";
-
-const params = new URLSearchParams(window.location.search);
-const isLocal = params.get("flow") === "local";
-const requestedStep = params.get("step");
-
-// Step names valid within the selected flow (drives deep-link vs. auth phase).
-const CLOUD_STEPS = ["start", "company", "agent", "task"] as const;
-const LOCAL_STEPS = ["start", "email", "company", "agent", "adapter", "task"] as const;
-const flowSteps: readonly string[] = isLocal ? LOCAL_STEPS : CLOUD_STEPS;
-const isFlowStep = requestedStep !== null && flowSteps.includes(requestedStep);
-
-const flowStart = isFlowStep ? requestedStep! : "start";
-// Which phase the harness opens on. The local flow skips sign-in entirely, so
-// it always opens on its welcome screen; cloud still walks account → OTP first.
-const initialPhase: Phase = isLocal
- ? "flow"
- : isFlowStep
- ? "flow"
- : requestedStep === "otp"
- ? "otp"
- : "account";
-
-function Preview() {
- const [phase, setPhase] = useState(initialPhase);
- const authVisible = phase === "account" || phase === "otp";
- const exitTimer = useRef(undefined);
-
- useEffect(() => () => window.clearTimeout(exitTimer.current), []);
-
- // Finishing sign-in fades the auth card + orbiting paperclip all the way out
- // FIRST, and only then mounts the flow — so the backdrop is fully gone before
- // the next screen appears rather than the two overlapping.
- function finishAuth() {
- setPhase("exiting");
- exitTimer.current = window.setTimeout(
- () => setPhase("flow"),
- AUTH_EXIT_DURATION * 1000,
- );
- }
-
- return (
- <>
- {phase === "flow" &&
- (isLocal ? (
- {}} />
- ) : (
- {}} />
- ))}
-
- {phase === "otp" ? (
-
- ) : (
- setPhase("otp")} />
- )}
-
- >
- );
-}
-
-const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
-});
-
-createRoot(document.getElementById("root")!).render(
-
-
-
-
-
-
-
-
-
-
-
-
- ,
-);
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index 9529865165..d62334bef0 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -120,15 +120,6 @@ import {
AvatarGroupCount,
} from "@/components/ui/avatar";
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
-import {
- Chip,
- ChoiceCard,
- ConnectorRow,
- OnboardingCard,
- OnboardingHeading,
- Stepper,
-} from "@/components/onboarding/OnboardingPrimitives";
-import { CONNECTORS, MISSION_CHIPS } from "@/components/onboarding/onboarding-data";
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { EnforcementBanner } from "@/components/EnforcementBanner";
@@ -262,61 +253,6 @@ function SubSection({ title, children }: { title: string; children: React.ReactN
);
}
-// Onboarding flow primitives (ported prototype): interactive demos need local
-// selection state, mirroring how OnboardingFlow drives them.
-function OnboardingChipsShowcase() {
- const [active, setActive] = useState(MISSION_CHIPS[0] ?? null);
- return (
-
- }
- title="Create a hiring plan"
- description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
- selected={choice === "hiring"}
- onClick={() => setChoice("hiring")}
- />
- }
- title="Write a team strategy doc"
- description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
- selected={choice === "strategy"}
- onClick={() => setChoice("strategy")}
- />
-
- );
-}
-
// Onboarding seam (design §6 + §12.5): the TeamCard tile in its "Pick a starter
// team" 3-col grid, with the first defaultInstall tile selected.
function TeamCardShowcase() {
@@ -496,7 +432,6 @@ export function DesignGuide() {
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip",
- "AgentCapsule", "OnboardingCard", "Stepper", "Chip", "ChoiceCard", "ConnectorRow",
].map((name) => (
{name}
@@ -831,38 +766,6 @@ export function DesignGuide() {
- {/* ============================================================ */}
- {/* ONBOARDING FLOW */}
- {/* ============================================================ */}
-
-
- Primitives for the full-screen onboarding flow (
- components/onboarding/). Bespoke surfaces keep
- their prototype dimensions via verbatim size tokens (
- --sz-560px card,{" "}
- --sz-320px start tiles); type, fields, and focus
- treatment come from the shared primitives and scale.
-