feat(ui): port onboarding flow from prototype; add cloud + local variants (#10786)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - First-run onboarding is the subsystem that turns a brand-new install
into a working company: it creates the company, its goal, a lead agent,
and that agent's first task
> - The existing `OnboardingWizard` carried all of that wiring
correctly, but its UI had drifted from the current design direction, and
a separate design prototype (`paperclip-onboard`) existed as a
standalone visual mock with no backend
> - Porting the prototype's *logic* would have thrown away working,
well-tested backend orchestration; leaving the two apart meant the
design never shipped
> - Separately, cloud and local (self-hosted) installs need meaningfully
different first runs — local has no sign-in and must let the user pick a
locally-installed CLI adapter — so a single linear wizard could not
serve both
> - This pull request rebuilds the presentational layer from the
prototype on top of the existing backend orchestration, and splits it
into two thin flow containers over a shared core
> - The benefit is that the shipped onboarding matches the intended
design, cloud and local can diverge without duplicating logic, and each
can later ship to a different app version while sharing one set of step
components

## Linked Issues or Issue Description

No existing issue — describing inline (feature request).

**What problem does this solve?**
Onboarding is the first thing a new user sees, and the shipped wizard
had drifted from the current design. In parallel, cloud and local
installs need different first-run paths: local has no hosted sign-in,
and its agent runs on a CLI adapter installed on the user's machine,
which the cloud path never has to ask about. There was no way to express
that difference without either forking the whole wizard or bolting
conditionals onto a single linear flow.

**Proposed solution**
Extract the onboarding step views and shell into a shared core, then
compose two thin flow containers (cloud and local) over it. Keep all
backend orchestration in the existing `useOnboardingFlow` hook so no
working logic is rewritten.

**Alternatives considered**
- *Single flow with a `variant` prop* — most DRY, but the two flows are
intended to ship on different app versions, and a shared file would have
to be split later anyway.
- *Two fully independent copies* — simplest per-flow, but every shared
refinement (spacing, motion, copy) would have to be made twice and would
drift.

## What Changed

- **Shared core** under `ui/src/components/onboarding/`:
`OnboardingScaffold` owns the full-screen shell and the single
`AnimatePresence` step crossfade, so both flows transition identically;
step views (Start / Company / Agent / Task), `FooterNav`, `AgentPreview`
and the motion constants are extracted for reuse.
- **`CloudOnboardingFlow`** — `start → company → agent → task`; mounted
in the real app via `OnboardingWizardVariant`. Behaviour matches the
retired wizard, including `previewMock` and the existing-company ("add
an agent") entry point.
- **`LocalOnboardingFlow`** — skips sign-in and adds an optional email
ask (with a privacy assurance), a local model/adapter step that hires
with `requireEnvProbe: true`, and a "star us on GitHub" interstitial
before completing. **Harness-only for now** — the real app still mounts
the cloud flow.
- **Deleted `OnboardingWizard.tsx`** (1,786 lines); updated its
Storybook stories and the `OnboardingWizardVariant` test to the new
components.
- **Orbiting 3D paperclip backdrop** behind the auth and welcome screens
(`three`), code-split so it only downloads on those screens; honours
`prefers-reduced-motion` and disposes its GL context on unmount.
- **`motion`** added for step transitions and the agent-capsule
choreography.
- Visual values routed through design tokens per `DESIGN.md`; `Stepper`
generalized to take a step total (backward compatible); `/design-guide`
page and the component index updated.
- **Standalone preview harness** (`ui/onboarding-preview.html`) with
`?flow=` and `?step=` for backend-free review, wired as a second Vite
rollup input.
- **Adapter env probe bound to the adapter it ran against.**
`hireLeadAgent` reused `adapterEnvResult` for any adapter, so when a
hire failed and the user picked a *different* local adapter and retried,
the previous adapter's verdict satisfied the `requireEnvProbe` guard
while the hire posted the new adapter's config — hiring it unprobed. The
cache is now keyed on the adapter type plus the exact config posted to
the test endpoint, the config is built once and shared by probe and
hire, a failed probe clears the cache, and `clearAdapterEnvResult()`
(called on adapter change) stops the step displaying a stale verdict.
Cloud is unaffected — it hires with `requireEnvProbe: false`. Reported
by Greptile.
- **E2E specs re-pointed at the new flow.** Four specs still drove the
deleted wizard (`onboarding`, `conference-room-typing-intro`,
`planning-mode-visual-verification`, `nux-phase4-screenshots`) and
failed with `element(s) not found` on `"Name your company"` /
`input[placeholder="Acme Corp"]`. Rather than repeat the new drive
sequence four times, `tests/e2e/onboarding-flow.ts` adds one driver per
step (`startCloudOnboarding`, `completeCompanyStep`,
`completeAgentStep`, `completeTaskStep`, `completeCloudOnboarding`) and
the specs import it, so the next flow change touches a single file. Two
now-dead `**/test-environment` route stubs went with it — the cloud flow
hires with `requireEnvProbe: false`, so that probe never fires.

## Verification

- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `npx vitest run` over the onboarding suites
(`OnboardingWizardVariant`, `AgentCapsule`, `onboarding-launch`,
`onboarding-goal`, `onboarding-route`, `onboarding-adapter-config`) — 33
tests pass.
- `pnpm --filter @paperclipai/ui build` — succeeds; the three.js chunk
splits out separately (522 kB raw / 133 kB gzip) rather than entering
the main bundle.
- Both flows driven end-to-end in the preview harness in `previewMock`
(no database writes), plus the cloud flow rendered in the real
authenticated app at `/onboarding` to confirm the mount swap.
- The four re-pointed e2e specs pass locally against the new flow.
- New `ui/src/hooks/useOnboardingFlow.test.tsx` — 4 cases pinning the
adapter-probe cache (switch-adapter retry, cold path, explicit clear,
and the cloud flow's `requireEnvProbe: false`). Verified non-vacuous:
the switch-adapter case fails against the pre-fix code.
- Rebased onto current `master`; `pnpm-lock.yaml` is deliberately
**not** committed — `.github/workflows/pr.yml` regenerates it when a
manifest changes and shares it with downstream jobs as the `pr-lockfile`
artifact.

## Risks

- **Deleting `OnboardingWizard.tsx` is the one change that alters
existing app behaviour.** The cloud flow is intended to be
behaviour-equivalent, and its entry points are covered by the updated
`OnboardingWizardVariant` test, but this is the area to review most
closely.
- **Conflict risk with open PRs that touch the old wizard**: #9900,
#9501, #8982 and #6636 all modify
`ui/src/components/OnboardingWizard.tsx`, which this PR removes.
Whichever lands second will need its change re-applied to the new step
components. Flagging so ordering can be decided deliberately.
- **New dependencies**: `motion` and `three` (+ `@types/three`). `three`
is large, so it is lazily imported and code-split — it does not affect
the main bundle. Both are MIT.
- The **local flow is not reachable in the app** yet (harness/canary
only), so it carries no runtime risk today; wiring it up is a follow-up.
- The auth screens remain **presentational only** — they are not wired
to real auth, unchanged from before this PR.
- **Pre-existing, not introduced here:** `OnboardingWizardVariant`
renders outside `<Routes>` in `App.tsx`, so its `useParams()` never
resolves `:companyPrefix` and `/{prefix}/onboarding` opens the welcome
screen instead of jumping to the agent step. `master` has the identical
structure, so this PR faithfully ports existing behaviour; the working
"add an agent" entry is the launcher card behind the overlay, which is
what the screenshot spec drives. Worth a separate fix.

## Model Used

Claude Opus 5 (`claude-opus-5`) via Claude Code, with extended thinking
and tool use (repo search/edit, local test + build execution, and
browser-driven visual verification of the rendered flows). Portions of
the session also ran on `claude-opus-4-8` and `claude-fable-5`.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tonio 2026-08-06 23:35:05 -07:00 committed by GitHub
parent b67c512f82
commit 11e56654f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 3648 additions and 2090 deletions

View File

@ -7,6 +7,13 @@
"runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"],
"port": 3108,
"autoPort": false
},
{
"name": "ui-preview",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@paperclipai/ui", "exec", "vite"],
"port": 5188,
"autoPort": true
}
]
}

View File

@ -188,6 +188,24 @@ Use in property rows, comment headers, assignee displays, and anywhere a user/ag
**File:** `CompanySwitcher.tsx`
**Usage:** Company selector dropdown in sidebar header.
### AgentCapsule
**File:** `AgentCapsule.tsx`
**Props:** `state: "slot" | "configured" | "online"`, `gradient?: 110`, `size?: "sm" | "md" | "lg" | {width,height}`, `glow?: "green" | "blue"`
**Usage:** The brand "capsule is the agent" pill; evolves in place across onboarding steps. Fill uses `--agent-Na/Nb` gradient tokens; honors `prefers-reduced-motion`.
### Onboarding primitives (OnboardingCard, OnboardingHeading, Stepper, Chip, ChoiceCard, ConnectorRow)
**File:** `onboarding/OnboardingPrimitives.tsx`
**Usage:** Presentational pieces for the full-screen onboarding flow (`onboarding/OnboardingFlow.tsx`): the 560px card frame (`--sz-560px`), display heading + lede (text-4xl), 3-segment stepper, selectable mission chips, selectable choice cards, and connector rows. Bespoke dimensions route through verbatim `--sz-*` tokens; fields inside the flow use the shared Input/Textarea/Select/Label primitives.
```tsx
<OnboardingCard>
<Stepper step={2} />
<OnboardingHeading title="Create your first agent" lede="..." />
</OnboardingCard>
```
---
## Layout Components

View File

@ -1,32 +1,25 @@
import { test, expect } from "@playwright/test";
import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow";
/**
* E2E: post-wizard onboarding launch.
* E2E: post-onboarding launch.
*
* Completing the onboarding wizard now creates the first assigned task and
* lands the user on the company dashboard. The chat intro still has unit
* coverage in BoardChat tests; the wizard handoff no longer routes there.
* Completing the onboarding flow creates the first assigned task and lands the
* user on the company dashboard. The chat intro still has unit coverage in
* BoardChat tests; the onboarding handoff no longer routes there.
*/
const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`;
const MISSION = "Verify the dashboard launch survives the wizard handoff.";
const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan";
const MISSION = "Verify the dashboard launch survives the onboarding handoff.";
test.describe("Dashboard launch after onboarding wizard", () => {
test.describe("Dashboard launch after onboarding", () => {
test("creates the first task and opens the dashboard", async ({
page,
baseURL,
}) => {
// Intercept env-test → instant pass (avoid running a real CLI check).
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "pass", checks: [] }),
}),
);
// Intercept hire → perform a REAL hire server-side with an inert http
// adapter so no real agent process spawns.
// adapter so no real agent process spawns. (The cloud flow hires with
// requireEnvProbe: false, so there is no adapter-environment probe to stub.)
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
@ -54,38 +47,13 @@ test.describe("Dashboard launch after onboarding wizard", () => {
await page.goto("/onboarding");
// Launcher card path (existing companies) — enter the wizard if the
// route shows a launcher instead of opening the wizard directly.
const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
if (await startBtn.count()) await startBtn.first().click();
// Step 0: front door (skipped when the wizard opens on the create path).
const frontDoor = page.getByText("Build a new company");
if (await frontDoor.count()) await frontDoor.first().click();
// Step 1: company name.
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
// Step 2: mission (direct path default).
await page
.getByPlaceholder("What is your team trying to achieve?")
.fill(MISSION);
await page.getByRole("button", { name: /Confirm mission/ }).click();
// Step 3: lead name (prefilled) → Next.
await page.waitForSelector('input[placeholder="Chief of staff"]', {
timeout: 15_000,
// Welcome → company (name + mission) → agent (role picker) → first task.
// "Get started" on the task step creates the task and opens the dashboard.
await completeCloudOnboarding(page, {
companyName: COMPANY_NAME,
mission: MISSION,
choice: "hiring",
});
await page.getByRole("button", { name: /^Next/ }).click();
// Step 4: adapter (claude_local default); heartbeat is intercepted.
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
// Step 5: review → Get started creates the first task and opens dashboard.
const getStarted = page.getByRole("button", { name: /Get started/ });
await getStarted.waitFor({ timeout: 20_000 });
await getStarted.click();
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
@ -98,8 +66,8 @@ test.describe("Dashboard launch after onboarding wizard", () => {
const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
expect(issuesRes.ok()).toBe(true);
const issues = await issuesRes.json();
const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE);
const firstTask = issues.find((candidate: { title: string }) => candidate.title === HIRING_TASK_TITLE);
expect(firstTask).toBeTruthy();
await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(HIRING_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
});
});

View File

@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEFAULT_ROLE, startCloudOnboarding } from "./onboarding-flow";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -9,40 +10,43 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
* NUX Phase 4 visual QA screenshot capture.
*
* Boots a throwaway local_trusted instance (see playwright.config.ts webServer)
* and captures screenshots of every surface integrated by NUX Phases 13:
* - "Build a new company" step 1 (company name) + step 2 (mission)
* - Team-lead hire step (capsule wizard, PAP-125)
* - Onboarding front door (path picker)
* - "Add agents to your org" growth intake
* and captures screenshots of every integrated onboarding surface:
* - Welcome screen (path picker)
* - Company step (name + mission)
* - Create-your-first-agent step (role picker + capsule)
* - First-task step
* - "Add an agent to an existing company" entry (/:prefix/onboarding)
* - Conference Room (BoardChat) shell + composer + activity feed
* - Artifacts page
*
* These are structural/rendering checks LLM-dependent streaming (CEO chat
* responses, hiring-plan generation) is verified separately on an LLM-backed
* instance. Screenshots land in ./nux-phase4-shots for upload as evidence.
* instance. Screenshots land in ./test-results for upload as evidence.
*/
// Write under the gitignored test-results dir so re-runs leave no untracked
// noise; screenshots are uploaded to the issue as QA evidence, not committed.
const SHOT_DIR = path.join(__dirname, "test-results", "nux-phase4-shots");
const SHOTS = [
"01-welcome.png",
"02-company.png",
"03-agent.png",
"04-first-task.png",
"05-add-agent.png",
"06-board-chat.png",
"07-artifacts.png",
];
function shot(name: string) {
fs.mkdirSync(SHOT_DIR, { recursive: true });
return path.join(SHOT_DIR, name);
}
async function openWizard(page: import("@playwright/test").Page) {
await page.goto("/onboarding");
const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ });
if (await startBtn.count()) {
await startBtn.first().click();
}
}
test.describe("NUX Phase 4 visual QA", () => {
test("captures every integrated surface", async ({ page }) => {
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
// experimental flag on for this throwaway instance before driving them.
// Conference Room is flag-gated default-OFF: turn the experimental flag on
// for this throwaway instance before driving that surface (Section C).
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
@ -57,36 +61,43 @@ test.describe("NUX Phase 4 visual QA", () => {
const baseUrl =
"http://127.0.0.1:" + (process.env.PAPERCLIP_E2E_PORT ?? "3199");
// ── Section A: create-company path (name → mission → hire) ────────────
await openWizard(page);
// Front door shows when the wizard doesn't open directly on the create
// path (e.g. another spec already created a company on this instance).
const createCard = page.getByRole("button", { name: /Build a new company/ });
if (await createCard.count()) {
await createCard.first().click();
}
// ── Section A: the cloud flow, step by step ───────────────────────────
await page.goto("/onboarding");
await expect(
page.getByRole("heading", { name: "Name your company" }),
page.getByRole("heading", { name: "Welcome to Paperclip!" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics");
await page.screenshot({ path: shot("02-create-name.png") });
await page.screenshot({ path: shot("01-welcome.png") });
await page.getByRole("button", { name: /^Next/ }).click();
await startCloudOnboarding(page);
// Capture the company step populated but not yet submitted, then submit.
await expect(
page.getByRole("heading", { name: "Define your mission" }),
).toBeVisible({ timeout: 10_000 });
page.getByRole("heading", { name: "What is the name of your company or team?" }),
).toBeVisible({ timeout: 15_000 });
await page.locator("#onboarding-company-name").fill("QA Robotics");
await page
.getByPlaceholder("What is your team trying to achieve?")
.locator("#onboarding-mission")
.fill("Build affordable home robots that handle household chores.");
await page.screenshot({ path: shot("03-create-mission.png") });
await page.screenshot({ path: shot("02-company.png") });
await page.getByRole("button", { name: /^Next/ }).click();
// Step 2 advances via "Confirm mission" (creates the company + goal);
// step 3 is the team-lead naming step of the capsule wizard.
await page.getByRole("button", { name: /Confirm mission/ }).click();
await page.waitForSelector('input[placeholder="Chief of staff"]', {
timeout: 30_000,
});
await page.screenshot({ path: shot("04-hire-team-lead.png") });
// Agent step: pick a role so the capsule + preview render, then capture
// before hiring.
await expect(
page.getByRole("heading", { name: "Create your first agent" }),
).toBeVisible({ timeout: 30_000 });
await page.locator("#onboarding-agent-role").click();
await page.getByRole("option", { name: DEFAULT_ROLE, exact: true }).click();
await page.screenshot({ path: shot("03-agent.png") });
await page.getByRole("button", { name: /^Create/ }).click();
// First-task step: select a choice so the card's selected state is visible.
await expect(
page.getByRole("heading", { name: "Assign your agent a first task" }),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Create a hiring plan/ }).click();
await page.screenshot({ path: shot("04-first-task.png") });
// The company just created anchors the route-scoped sections below.
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
@ -95,39 +106,23 @@ test.describe("NUX Phase 4 visual QA", () => {
const qaCompany = (Array.isArray(companies) ? companies : []).find(
(c: { name: string }) => c.name === "QA Robotics",
);
expect(qaCompany, "wizard should have created QA Robotics").toBeTruthy();
expect(qaCompany, "onboarding should have created QA Robotics").toBeTruthy();
const prefix: string = qaCompany.issuePrefix;
// ── Section B: front door + growth intake ─────────────────────────────
// ── Section B: "add an agent to an existing company" entry ────────────
// OnboardingWizardVariant renders outside <Routes> (App.tsx), so it never
// sees the :companyPrefix param and the company-scoped route still opens on
// the welcome screen. The real existing-company entry is the launcher card
// behind it: dismiss the overlay, then "Add Agent" opens onboarding scoped
// to this company, which skips company creation and starts at the agent step.
await page.evaluate(() => window.localStorage.clear());
await openWizard(page);
// Reach the full-screen front door (step 0): either it shows directly or
// "← Back to start" returns to it from the create step.
if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) {
await page.getByRole("button", { name: /Back to start/ }).click();
}
await page.goto(`/${prefix}/onboarding`);
await page.getByRole("button", { name: "Close onboarding" }).click();
await page.getByRole("button", { name: "Add Agent" }).click();
await expect(
page.getByRole("heading", { name: "Welcome to Paperclip" }),
).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole("heading", { name: "Build a new company" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Add agents to your org" }),
).toBeVisible();
await page.screenshot({ path: shot("01-front-door.png") });
await page.getByRole("button", { name: /Add agents to your org/ }).click();
// The grow path shares step 1 (company name) before its step-2 intake.
await expect(
page.getByRole("heading", { name: "Name your company" }),
).toBeVisible({ timeout: 10_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics Grow");
await page.getByRole("button", { name: /^Next/ }).click();
await expect(
page.getByRole("heading", { name: /Tell us about your team/ }),
).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: shot("05-growth-intake.png") });
page.getByRole("heading", { name: "Create your first agent" }),
).toBeVisible({ timeout: 20_000 });
await page.screenshot({ path: shot("05-add-agent.png") });
// ── Section C: Conference Room (BoardChat) ────────────────────────────
// Visit the company dashboard first so CompanyContext selects the company
@ -152,15 +147,7 @@ test.describe("NUX Phase 4 visual QA", () => {
await page.waitForTimeout(1_000);
await page.screenshot({ path: shot("07-artifacts.png") });
for (const f of [
"01-front-door.png",
"02-create-name.png",
"03-create-mission.png",
"04-hire-team-lead.png",
"05-growth-intake.png",
"06-board-chat.png",
"07-artifacts.png",
]) {
for (const f of SHOTS) {
const p = shot(f);
expect(fs.existsSync(p), `missing ${f}`).toBe(true);
expect(fs.statSync(p).size, `empty ${f}`).toBeGreaterThan(1_000);

View File

@ -0,0 +1,134 @@
import { expect, type Page } from "@playwright/test";
/**
* Shared driver for the cloud onboarding flow (ui/src/components/onboarding/).
*
* The flow replaced the retired OnboardingWizard, which had a front door, a
* separate company-name step, a separate mission step, an adapter step and a
* review step. The cloud flow is four screens:
*
* start "Welcome to Paperclip!" (unnumbered)
* company name + mission on one card; "Next" creates the company + goal
* agent role picker (+ optional name); "Create" hires the lead agent
* task first-task choice; "Get started" launches it and opens the dashboard
*
* Four specs drove the old wizard's selectors, so the step drivers live here
* rather than being copy-pasted: when the flow changes again, this is the one
* file to update.
*/
/** Default role picked in the agent step. */
export const DEFAULT_ROLE = "Chief of Staff";
/**
* Name the agent step auto-fills when DEFAULT_ROLE is picked. Selecting a role
* populates the (optional) name field with the role's acronym see
* ROLE_ACRONYMS in ui/src/components/onboarding/onboarding-data.ts.
*/
export const DEFAULT_ROLE_ACRONYM = "COS";
/** Title of the task created by the "hiring" first-task choice. */
export const HIRING_TASK_TITLE = "Hire your first engineer and create a hiring plan";
export type FirstTaskChoice = "hiring" | "strategy" | "custom";
/**
* Matches each first-task ChoiceCard. The cards are buttons whose accessible
* name is title + description, so these match on the title fragment only.
*/
const CHOICE_CARD: Record<FirstTaskChoice, RegExp> = {
hiring: /Create a hiring plan/,
strategy: /Write a team strategy doc/,
// Trailing ellipsis in the UI copy is deliberately not matched.
custom: /Write your own task/,
};
/** Step 0 — the welcome screen. Advances to the first numbered step. */
export async function startCloudOnboarding(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Welcome to Paperclip!" })).toBeVisible({
timeout: 15_000,
});
await page
.getByRole("button", { name: /Set up Paperclip for your company or team/ })
.click();
}
/**
* Step 1 company name + mission on a single card. "Next" persists the company
* and its company-level goal, then advances to the agent step.
*/
export async function completeCompanyStep(
page: Page,
{ companyName, mission }: { companyName: string; mission: string },
): Promise<void> {
await expect(
page.getByRole("heading", { name: "What is the name of your company or team?" }),
).toBeVisible({ timeout: 15_000 });
await page.locator("#onboarding-company-name").fill(companyName);
await page.locator("#onboarding-mission").fill(mission);
await page.getByRole("button", { name: /^Next/ }).click();
}
/**
* Step 2 role picker (a Radix select) plus an optional name. "Create" hires
* the lead agent. Pass `name` to override the acronym the role auto-fills.
*/
export async function completeAgentStep(
page: Page,
{ role = DEFAULT_ROLE, name }: { role?: string; name?: string } = {},
): Promise<void> {
await expect(page.getByRole("heading", { name: "Create your first agent" })).toBeVisible({
timeout: 30_000,
});
await page.locator("#onboarding-agent-role").click();
await page.getByRole("option", { name: role, exact: true }).click();
if (name !== undefined) {
await page.locator("#onboarding-agent-name").fill(name);
}
await page.getByRole("button", { name: /^Create/ }).click();
}
/**
* Step 3 pick the first task and launch it. "Get started" creates the task
* and navigates to the company dashboard.
*/
export async function completeTaskStep(
page: Page,
{ choice = "hiring", customTask }: { choice?: FirstTaskChoice; customTask?: string } = {},
): Promise<void> {
await expect(
page.getByRole("heading", { name: "Assign your agent a first task" }),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: CHOICE_CARD[choice] }).click();
if (choice === "custom") {
await page.getByPlaceholder("Describe the first task").fill(customTask ?? "");
}
await page.getByRole("button", { name: /Get started/ }).click();
}
/**
* Drives the whole cloud flow from an already-loaded /onboarding route through
* to the dashboard navigation. Callers that need to assert or screenshot
* between steps should call the individual step drivers instead.
*/
export async function completeCloudOnboarding(
page: Page,
{
companyName,
mission,
role = DEFAULT_ROLE,
choice = "hiring",
customTask,
}: {
companyName: string;
mission: string;
role?: string;
choice?: FirstTaskChoice;
customTask?: string;
},
): Promise<void> {
await startCloudOnboarding(page);
await completeCompanyStep(page, { companyName, mission });
await completeAgentStep(page, { role });
await completeTaskStep(page, { choice, customTask });
}

View File

@ -1,79 +1,46 @@
import { test, expect } from "@playwright/test";
import { completeCompanyStep, startCloudOnboarding } from "./onboarding-flow";
/**
* E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard).
* E2E: cloud onboarding flow.
*
* The wizard now opens on a front door (path picker) and the "Create a new
* company" path runs:
* Step 0 Front door (Create a new company / Level up existing)
* Step 1a Name your company
* Step 1b Define your mission (direct or guided)
* Step 2 Hire your team lead (adapter picker)
* Step 3+ Launch celebration CEO chat hiring plan orientation
* The flow opens on a welcome screen and then runs three numbered steps:
* Step 0 Welcome (unnumbered path picker)
* Step 1 Company name + mission
* Step 2 Create your first agent (role picker)
* Step 3 Assign your agent a first task
*
* This test covers the deterministic, LLM-free core: it drives the front door
* through company naming + mission definition (which creates the company and a
* company-level goal) and verifies the wizard advances to the team-lead step.
* This test covers the deterministic, LLM-free core: it drives the welcome
* screen through the company step (which creates the company and a
* company-level goal) and verifies the flow advances to the agent step.
*
* The tail (CEO chat at step 4, hiring-plan generation at step 5, final
* landing) depends on a live LLM and is verified separately during manual /
* LLM-backed QA see PAP-50. Surface-level rendering of every step is
* snapshotted by nux-phase4-screenshots.spec.ts.
* The tail (hiring the agent, launching the first task) is covered by
* conference-room-typing-intro.spec.ts; surface-level rendering of every step
* is snapshotted by nux-phase4-screenshots.spec.ts.
*/
const COMPANY_NAME = `E2E-Test-${Date.now()}`;
const MISSION = "Build affordable home robots that handle household chores.";
test.describe("Onboarding wizard", () => {
test("create-company path: name + mission creates company and goal", async ({
test.describe("Onboarding flow", () => {
test("company step: name + mission creates company and goal", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
// experimental flag on for this throwaway instance before driving them.
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
expect(flagRes.ok()).toBe(true);
await page.goto("/onboarding");
// The wizard may open on a launcher card or directly on the capsule
// wizard; the front door (step 0) requires a click into the create path.
const startBtn = page.getByRole("button", {
name: /Start Onboarding|New Company|Add Agent/,
await startCloudOnboarding(page);
await completeCompanyStep(page, {
companyName: COMPANY_NAME,
mission: MISSION,
});
if (await startBtn.count()) {
await startBtn.first().click();
}
const createCard = page.getByRole("button", { name: /Build a new company/ });
if (await createCard.count()) {
await createCard.first().click();
}
// Step 1 — Name your company.
// Reaching the agent step means the company + goal writes succeeded.
await expect(
page.getByRole("heading", { name: "Name your company" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
// Step 2 — Define your mission (direct entry is the default path).
await expect(
page.getByRole("heading", { name: "Define your mission" }),
).toBeVisible({ timeout: 10_000 });
await page
.getByPlaceholder("What is your team trying to achieve?")
.fill(MISSION);
// "Confirm mission" creates the company + a company-level goal, then
// advances to the team-lead naming step of the capsule wizard.
await page.getByRole("button", { name: /Confirm mission/ }).click();
await page.waitForSelector('input[placeholder="Chief of staff"]', {
timeout: 30_000,
});
page.getByRole("heading", { name: "Create your first agent" }),
).toBeVisible({ timeout: 30_000 });
// Verify the company + company-level goal were persisted.
const baseUrl = page.url().split("/").slice(0, 3).join("/");
@ -95,7 +62,7 @@ test.describe("Onboarding wizard", () => {
);
expect(companyGoal, "a company-level goal should be created").toBeTruthy();
// The expanded wizard must not crash the app (Rules-of-Hooks regression).
// The flow must not crash the app (Rules-of-Hooks regression).
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
});

View File

@ -1,20 +1,21 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow";
const AGENT_NAME = "Chief of staff";
const TASK_TITLE = "Hire your first engineer and create a hiring plan";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const timestamp = Date.now();
const companyName = `PAP-3413-${timestamp}`;
const screenshotDir = "test-results/planning-mode";
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "pass", checks: [] }),
}),
);
// Resolve against this file, not the cwd, so screenshots land in the
// gitignored tests/e2e/test-results/ rather than an untracked dir at the
// repo root that a contributor could commit by accident.
const screenshotDir = path.join(__dirname, "test-results", "planning-mode");
// Intercept hire → perform a REAL hire server-side with an inert http adapter
// so no real agent process spawns. (The cloud flow hires with
// requireEnvProbe: false, so there is no adapter-environment probe to stub.)
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
@ -41,31 +42,15 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
});
await page.goto("/onboarding");
const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ });
if (await startBtn.count()) await startBtn.first().click();
const createCard = page.getByRole("button", { name: /Build a new company/ });
if (await createCard.count()) await createCard.first().click();
// This spec only needs a company with a seeded first task to screenshot the
// planning-mode UI against; drive the whole onboarding flow to get one.
await completeCloudOnboarding(page, {
companyName,
mission: "Capture planning mode visual evidence for the graduated task UI.",
choice: "hiring",
});
await expect(page.getByRole("heading", { name: "Name your company" })).toBeVisible({ timeout: 15_000 });
await page.locator('input[placeholder="Acme Corp"]').fill(companyName);
await page.getByRole("button", { name: /^Next/ }).click();
await expect(page.getByRole("heading", { name: "Define your mission" })).toBeVisible({ timeout: 30_000 });
await page
.getByPlaceholder("What is your team trying to achieve?")
.fill("Capture planning mode visual evidence for the graduated task UI.");
await page.getByRole("button", { name: /Confirm mission/ }).click();
await page.waitForSelector('input[placeholder="Chief of staff"]', { timeout: 30_000 });
await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Get started/ }).click();
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
const baseOrigin = new URL(page.url()).origin;
@ -79,7 +64,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const issues = await issueRes.json();
const planningSeedIssue = issues.find(
(candidate: { id: string; identifier?: string; title: string }) =>
candidate.title === TASK_TITLE,
candidate.title === HIRING_TASK_TITLE,
);
expect(planningSeedIssue).toBeTruthy();

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Onboarding Preview</title>
<style>
/* Unlayered override beats Tailwind's @layer base: index.css pins
html,body to viewport height with overflow hidden (correct for the app
shell). This standalone page needs to scroll. */
html, body { height: auto; min-height: 100%; overflow-y: auto; }
</style>
<script>
document.documentElement.classList.add("dark");
document.documentElement.style.colorScheme = "dark";
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/onboarding-preview-main.tsx"></script>
</body>
</html>

View File

@ -59,6 +59,7 @@
"lexical": "0.48.0",
"lucide-react": "^0.577.0",
"mermaid": "^11.16.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@ -67,7 +68,8 @@
"react-resizable-panels": "^4.12.2",
"react-router-dom": "^7.18.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"three": "^0.185.1"
},
"devDependencies": {
"@storybook/addon-a11y": "10.5.4",
@ -77,6 +79,7 @@
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.185.1",
"@vitejs/plugin-react": "^4.3.4",
"storybook": "10.5.5",
"tailwindcss": "^4.3.2",

View File

@ -1,4 +1,5 @@
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
@ -8,14 +9,14 @@ import { cn } from "@/lib/utils";
* A single agent is drawn as a tall pill (proportion 1:2, radius 9999px)
* that moves through three states as the agent comes to life:
*
* - `slot` dashed outline, gently pulsing. An empty agent slot.
* - `slot` static dashed outline. An empty agent slot.
* - `configured` solid stroke. Agent named / model picked, not yet live.
* - `online` brand agent-gradient liquid rises to fill the capsule,
* - `online` brand agent-gradient fill radiates outward from the center,
* which then breathes with an online-pulse ring (green by
* default, or blue via `glow="blue"`).
*
* The three states are drawn as stacked layers (a dashed outline, a solid
* stroke, and the rising liquid) that cross-fade by opacity. Because
* stroke, and the radial fill) that cross-fade by opacity. Because
* `border-style` is not animatable, the dashedsolid morph is realized as the
* dashed layer fading out while the solid layer fades in so the SAME capsule
* can evolve in place across a flow (PAP-125, Option 4 wizard).
@ -24,7 +25,7 @@ import { cn } from "@/lib/utils";
* `--agent-Na` (top) `--agent-Nb` (bottom); pick which one with `gradient`
* (110). Size is a preset (`sm` | `md` | `lg`) or an explicit pixel pair so
* the component is reusable app-wide. `prefers-reduced-motion` is honored in
* CSS the liquid rise, layer cross-fade and both pulses are skipped and the
* CSS the radial fill, layer cross-fade and both pulses are skipped and the
* final state is rendered statically.
*/
@ -60,10 +61,21 @@ export interface AgentCapsuleProps
size?: AgentCapsuleSizePreset | { width: number; height: number };
/** Online-pulse colour (only applies in the `online` state). Defaults to `green`. */
glow?: AgentCapsuleGlow;
/**
* Slotconfigured morph as a draw-on: the solid outline is traced around the
* perimeter over the still-visible dashed outline (which fades once the draw
* completes), instead of the default border cross-fade. Honors reduced
* motion by rendering the final state instantly.
*/
strokeDraw?: boolean;
/** Accessible label; defaults to a description of the state. */
"aria-label"?: string;
}
/** Duration of the strokeDraw perimeter trace; the dashed layer fades after it. */
const STROKE_DRAW_SECONDS = 0.9;
const STROKE_DRAW_EASE = [0.16, 1, 0.3, 1] as const;
/** Normalize a (possibly out-of-range) gradient index to 1…AGENT_GRADIENT_COUNT. */
function normalizeGradient(gradient: number): number {
const n = Math.trunc(gradient);
@ -75,6 +87,7 @@ export function AgentCapsule({
gradient = 1,
size = "md",
glow = "green",
strokeDraw = false,
className,
style,
"aria-label": ariaLabel,
@ -83,6 +96,8 @@ export function AgentCapsule({
const dims = typeof size === "string" ? SIZE_PRESETS[size] : size;
const idx = normalizeGradient(gradient);
const fill = `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`;
const reducedMotion = useReducedMotion();
const drawn = state === "configured" || state === "online";
return (
<div
@ -99,29 +114,68 @@ export function AgentCapsule({
style={{ width: dims.width, height: dims.height, ...style }}
{...rest}
>
{/* Dashed outline an empty agent slot. Visible (and pulsing) only in
the slot state; cross-fades out as the capsule is configured. */}
{/* Dashed outline an empty agent slot. Visible (static) only in the
slot state; cross-fades out as the capsule is configured. In
strokeDraw mode it instead stays put while the solid outline is
traced over it, then fades once the draw completes. */}
<span
aria-hidden="true"
className={cn(
"agent-cap-dash agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-dashed border-muted-foreground/60",
state === "slot" ? "agent-cap-slot opacity-100" : "opacity-0",
)}
style={
strokeDraw && state === "configured" && !reducedMotion
? { transitionDelay: `${STROKE_DRAW_SECONDS}s` }
: undefined
}
/>
{/* Solid stroke agent configured, not yet live. Cross-fades in on top
of the dashed layer, then out as the liquid rises. */}
<span
aria-hidden="true"
className={cn(
"agent-cap-stroke agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-solid border-foreground/70",
state === "configured" ? "opacity-100" : "opacity-0",
)}
/>
{/* Brand-gradient liquid — rises to fill the capsule when online. */}
{/* Solid stroke agent configured, not yet live. Default: cross-fades
in on top of the dashed layer. strokeDraw: an SVG outline traced
around the perimeter (pathLength 01) over the dashed layer. */}
{strokeDraw ? (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
width="100%"
height="100%"
viewBox={`0 0 ${dims.width} ${dims.height}`}
fill="none"
>
<motion.rect
x={1}
y={1}
width={dims.width - 2}
height={dims.height - 2}
rx={(dims.width - 2) / 2}
strokeWidth={2}
className="stroke-foreground/70"
initial={false}
animate={{ pathLength: drawn ? 1 : 0 }}
transition={
reducedMotion
? { duration: 0 }
: { duration: STROKE_DRAW_SECONDS, ease: STROKE_DRAW_EASE }
}
/>
</svg>
) : (
<span
aria-hidden="true"
className={cn(
"agent-cap-stroke agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-solid border-foreground/70",
state === "configured" && "opacity-100",
// Online: carry the solid outline over, then fade it as the fill completes.
state === "online" && "agent-cap-stroke-carryover",
state === "slot" && "opacity-0",
)}
/>
)}
{/* Brand-gradient fill — radiates outward from the center when online. */}
{state === "online" ? (
<span
aria-hidden="true"
className="agent-cap-liquid absolute inset-x-0 bottom-0 block h-full"
className="agent-cap-liquid absolute inset-0 block"
style={{ background: fill }}
/>
) : null}

File diff suppressed because it is too large Load Diff

View File

@ -3,25 +3,51 @@
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 } }) => (
<div
data-testid="onboarding-flow"
data-initial-step={props.initialStep}
data-company={props.existingCompany?.id ?? ""}
/>
),
}));
import { OnboardingWizardVariant } from "./OnboardingWizardVariant";
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
function baseDialog(overrides: Record<string, unknown> = {}) {
return {
onboardingOpen: false,
onboardingOptions: {},
closeOnboarding: vi.fn(),
onboardingRouteDismissed: true,
setOnboardingRouteDismissed: vi.fn(),
...overrides,
};
}
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("./OnboardingWizard", () => ({
OnboardingWizard: () => <div data-testid="wizard-capsule" />,
}));
describe("OnboardingWizardVariant (PAP-138)", () => {
describe("OnboardingWizardVariant", () => {
let container: HTMLDivElement;
let root: Root | null = null;
function renderVariant() {
function render() {
root = createRoot(container);
flushSync(() => {
root!.render(<OnboardingWizardVariant />);
@ -42,11 +68,29 @@ describe("OnboardingWizardVariant (PAP-138)", () => {
vi.clearAllMocks();
});
it("renders the capsule wizard without reading the chat flag", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({});
renderVariant();
it("renders nothing when onboarding is closed", () => {
dialogState.value = baseDialog();
render();
expect(container.querySelector('[data-testid="onboarding-flow"]')).toBeNull();
});
expect(container.querySelector('[data-testid="wizard-capsule"]')).not.toBeNull();
expect(mockInstanceSettingsApi.getExperimental).not.toHaveBeenCalled();
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");
});
});

View File

@ -1,10 +1,72 @@
import { OnboardingWizard } from "./OnboardingWizard";
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";
/**
* Default onboarding wizard. Conference-room chat is now the only surface left
* behind `enableConferenceRoomChat`; onboarding stays available without that
* experimental flag.
* 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).
*/
export function OnboardingWizardVariant() {
return <OnboardingWizard />;
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 (
<CloudOnboardingFlow
initialStep={existingCompany ? "agent" : "start"}
existingCompany={
existingCompany
? { id: existingCompany.id, prefix: existingCompany.issuePrefix }
: undefined
}
onClose={handleClose}
/>
);
}

View File

@ -0,0 +1,47 @@
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 (
<motion.div
className="overflow-hidden"
initial={false}
animate={{ height: previewVisible ? "auto" : 0 }}
transition={{ duration: PREVIEW_REVEAL_DURATION, ease: STEP_EASE }}
>
<motion.div
className="mt-1 flex flex-col items-center gap-1"
initial={false}
animate={{ opacity: previewVisible ? 1 : 0 }}
transition={{
duration: PREVIEW_REVEAL_DURATION,
ease: STEP_EASE,
delay: previewVisible ? PREVIEW_REVEAL_DURATION * 0.25 : 0,
}}
>
<span className="flex h-6 items-center text-base font-semibold tracking-tight text-foreground">
{agentName || " "}
</span>
<span className="flex h-4 items-center text-xs text-muted-foreground">
{agentRole || " "}
</span>
</motion.div>
</motion.div>
);
}

View File

@ -0,0 +1,165 @@
// 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<Step>(initialStep);
const [companyName, setCompanyName] = useState("");
const [mission, setMission] = useState("");
const [agentRole, setAgentRole] = useState("");
const [agentName, setAgentName] = useState("");
const [taskChoice, setTaskChoice] = useState<FirstTaskChoice | null>(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 (
<OnboardingScaffold stepKey={step} onClose={onClose}>
{step === "start" && <StartStep onSetup={() => setStep("company")} />}
{step === "company" && (
<CompanyStep
{...stepper("company")}
companyName={companyName}
onCompanyNameChange={setCompanyName}
mission={mission}
onMissionChange={setMission}
onBack={() => setStep("start")}
onNext={handleCreateCompany}
loading={flow.loading}
/>
)}
{step === "agent" && (
<AgentStep
{...stepper("agent")}
agentRole={agentRole}
agentName={agentName}
onRoleChange={handleRoleChange}
onNameChange={setAgentName}
onBack={existingCompany ? () => onClose?.() : () => setStep("company")}
onNext={handleCreateAgent}
loading={flow.loading}
/>
)}
{step === "task" && (
<TaskStep
{...stepper("task")}
agentName={agentName}
agentRole={agentRole}
taskChoice={taskChoice}
onSelectChoice={setTaskChoice}
customTask={customTask}
onCustomTaskChange={setCustomTask}
onBack={() => setStep("agent")}
onGetStarted={handleGetStarted}
loading={flow.loading}
error={flow.error}
/>
)}
</OnboardingScaffold>
);
}

View File

@ -0,0 +1,50 @@
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 (
<div className="flex items-center justify-between pt-2">
{/* 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). */}
<Button
variant="ghost"
size="sm"
className="rounded-full has-[>svg]:pr-4"
onClick={onBack}
disabled={loading}
>
<ArrowLeft className="mr-1 size-3.5" />
Back
</Button>
<Button
size="lg"
className="rounded-full px-6"
onClick={onPrimary}
disabled={primaryDisabled || loading}
>
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : null}
{loading && loadingLabel ? loadingLabel : primaryLabel}
{!loading ? <ArrowRight className="ml-1 size-3.5" /> : null}
</Button>
</div>
);
}

View File

@ -0,0 +1,44 @@
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 (
<OnboardingCard className="text-center">
<div className="flex flex-col items-center gap-6">
<span className="grid size-16 place-items-center rounded-full bg-muted/50 text-foreground">
<Star className="size-7" />
</span>
<OnboardingHeading
title="Enjoying Paperclip so far?"
lede="Paperclip is open source. A GitHub star helps other people find it and means a lot to a small team — it takes two seconds."
center
/>
<div className="flex w-full flex-col gap-2">
<Button
size="lg"
className="w-full rounded-full"
onClick={() => {
window.open(PAPERCLIP_REPO_URL, "_blank", "noopener,noreferrer");
onContinue();
}}
>
<Github className="mr-1.5 size-4" />
Star on GitHub
</Button>
<Button variant="ghost" size="sm" className="rounded-full" onClick={onContinue}>
Not right now
</Button>
</div>
</div>
</OnboardingCard>
);
}

View File

@ -0,0 +1,228 @@
// 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<Step>(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<number | undefined>(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<FirstTaskChoice | null>(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 (
<OnboardingAuthBackdrop visible={welcomePhase === "welcome"} onClose={onClose}>
<StartStep onSetup={leaveWelcome} />
</OnboardingAuthBackdrop>
);
}
return (
<OnboardingScaffold stepKey={showGithubStar ? "github-star" : step} onClose={onClose}>
{showGithubStar ? (
<GithubStarInterstitial onContinue={completeOnboarding} />
) : (
<>
{step === "email" && (
<EmailStep
email={email}
onEmailChange={setEmail}
onContinue={() => setStep("company")}
onSkip={() => setStep("company")}
/>
)}
{step === "company" && (
<CompanyStep
{...stepper("company")}
companyName={companyName}
onCompanyNameChange={setCompanyName}
mission={mission}
onMissionChange={setMission}
onBack={() => setStep("email")}
onNext={handleCreateCompany}
loading={flow.loading}
/>
)}
{step === "agent" && (
<AgentStep
{...stepper("agent")}
agentRole={agentRole}
agentName={agentName}
onRoleChange={handleRoleChange}
onNameChange={setAgentName}
onBack={() => setStep("company")}
onNext={() => setStep("adapter")}
primaryLabel="Next"
/>
)}
{step === "adapter" && (
<AdapterStep
{...stepper("adapter")}
adapterType={adapterType}
onAdapterChange={handleAdapterChange}
agentName={agentName}
agentRole={agentRole}
onBack={() => setStep("agent")}
onNext={handleHireAgent}
loading={flow.loading}
/>
)}
{step === "task" && (
<TaskStep
{...stepper("task")}
agentName={agentName}
agentRole={agentRole}
taskChoice={taskChoice}
onSelectChoice={setTaskChoice}
customTask={customTask}
onCustomTaskChange={setCustomTask}
onBack={() => setStep("adapter")}
onGetStarted={handleGetStarted}
loading={flow.loading}
error={flow.error}
/>
)}
</>
)}
</OnboardingScaffold>
);
}

View File

@ -0,0 +1,102 @@
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 (
<div className={cn("pointer-events-none absolute inset-0", className)}>
<Suspense fallback={null}>
<PaperclipOrbit3D className="size-full" />
</Suspense>
{/* 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. */}
<div className="absolute inset-0 bg-background/20" />
</div>
);
}
/**
* 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 (
<AnimatePresence>
{visible ? (
<motion.div
key="onboarding-auth"
className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-background px-5 py-20"
initial={false}
exit={{ opacity: 0 }}
transition={{ duration: AUTH_EXIT_DURATION, ease: STEP_EASE }}
>
{/* 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. */}
<motion.div
className="pointer-events-none absolute inset-0 -z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: AUTH_EXIT_DURATION, ease: STEP_EASE }}
>
<PaperclipBackdropLayer />
</motion.div>
{onClose ? (
<button
onClick={onClose}
className="absolute left-4 top-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 transition-colors hover:text-foreground"
aria-label="Close onboarding"
>
<X className="size-5" />
</button>
) : null}
{/* The card lowers slightly and fades on the way out. */}
<motion.div
className="relative"
initial={false}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: AUTH_EXIT_DURATION, ease: STEP_EASE }}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}

View File

@ -0,0 +1,163 @@
// 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 (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path
fill="#4285F4"
d="M22.5 12.2c0-.7-.1-1.4-.2-2H12v3.9h5.9a5 5 0 0 1-2.2 3.3v2.7h3.6c2.1-2 3.2-4.9 3.2-7.9z"
/>
<path
fill="#34A853"
d="M12 23c2.9 0 5.4-1 7.2-2.6l-3.6-2.7c-1 .7-2.3 1.1-3.6 1.1-2.8 0-5.1-1.9-6-4.4H2.3v2.8A11 11 0 0 0 12 23z"
/>
<path fill="#FBBC05" d="M6 14.4a6.6 6.6 0 0 1 0-4.2V7.4H2.3a11 11 0 0 0 0 9.8L6 14.4z" />
<path
fill="#EA4335"
d="M12 5.4c1.6 0 3 .5 4.1 1.6l3.1-3.1A11 11 0 0 0 2.3 7.4L6 10.2c.9-2.6 3.2-4.8 6-4.8z"
/>
</svg>
);
}
function GithubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 8.8 21.5c.5.1.7-.2.7-.5v-1.8c-2.8.6-3.4-1.2-3.4-1.2-.5-1.2-1.1-1.5-1.1-1.5-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.3-4.5-1.1-4.5-4.9 0-1.1.4-2 1-2.7-.1-.3-.4-1.3.1-2.7 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 5 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.7.6.7 1 1.6 1 2.7 0 3.8-2.3 4.6-4.5 4.9.4.3.7.9.7 1.8v2.7c0 .3.2.6.7.5A10 10 0 0 0 12 2z"
/>
</svg>
);
}
function SocialButton({ mark, label }: { mark: React.ReactNode; label: string }) {
return (
<button
type="button"
className="flex flex-1 items-center justify-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-3 text-sm font-medium text-foreground transition-colors hover:border-muted-foreground"
>
<span className="size-(--sz-18px)">{mark}</span>
{label}
</button>
);
}
/** Create-account screen (visual only). */
export function AccountScreen({ onContinue }: { onContinue?: () => void }) {
return (
<OnboardingCard translucent>
<div className="space-y-6">
<OnboardingHeading
title="Create an account"
lede="Free while Paperclip is in beta. No credit card required."
center
/>
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-auth-email">Email</Label>
<Input id="onboarding-auth-email" type="email" placeholder="you@company.com" />
<span className="text-xs leading-snug text-muted-foreground">
We'll send you a 6-digit code to confirm your email.
</span>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-auth-password">Password</Label>
<Input id="onboarding-auth-password" type="password" placeholder="••••••••" />
<span className="text-xs leading-snug text-muted-foreground">At least 8 characters.</span>
</div>
<Button size="lg" className="w-full rounded-full" onClick={onContinue}>
Continue
</Button>
</div>
<div className="flex items-center gap-3.5 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
Or continue with
<span className="h-px flex-1 bg-border" />
</div>
<div className="flex gap-3">
<SocialButton mark={<GoogleMark className="size-(--sz-18px)" />} label="Google" />
<SocialButton mark={<GithubMark className="size-(--sz-18px)" />} label="GitHub" />
</div>
<p className="text-center text-xs text-muted-foreground">
Already have an account?{" "}
<span className="text-foreground underline-offset-2 hover:underline">Sign in</span>
</p>
</div>
</OnboardingCard>
);
}
/** Email OTP confirmation screen (visual only). */
export function OtpScreen({ email, onContinue }: { email?: string; onContinue?: () => void }) {
const [vals, setVals] = useState<string[]>(["", "", "", "", "", ""]);
const refs = useRef<Array<HTMLInputElement | null>>([]);
function setAt(i: number, v: string) {
setVals((a) => a.map((x, k) => (k === i ? v : x)));
}
function onChange(i: number, raw: string) {
const v = raw.replace(/\D/g, "").slice(0, 1);
setAt(i, v);
if (v && i < 5) refs.current[i + 1]?.focus();
}
function onKeyDown(i: number, e: React.KeyboardEvent) {
if (e.key === "Backspace" && !vals[i] && i > 0) refs.current[i - 1]?.focus();
}
const filled = vals.every(Boolean);
return (
<OnboardingCard translucent>
<div className="flex flex-col items-center gap-6">
<OnboardingHeading
title="Confirm your email"
lede={
<>
Enter the 6-digit code we sent to{" "}
<span className="text-foreground">{email || "your inbox"}</span>.
</>
}
center
/>
<div className="flex justify-center gap-2.5">
{vals.map((v, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el;
}}
inputMode="numeric"
maxLength={1}
value={v}
onChange={(e) => onChange(i, e.target.value)}
onKeyDown={(e) => onKeyDown(i, e)}
className="size-(--sz-52px) rounded-md border border-input bg-muted/30 text-center font-mono text-xl font-semibold text-foreground outline-none transition-shadow focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)"
/>
))}
</div>
<Button size="lg" className={cn("w-full rounded-full")} disabled={!filled} onClick={onContinue}>
Continue
</Button>
<p className="text-center text-xs text-muted-foreground">
Didn't get it?{" "}
<span className="text-foreground underline-offset-2 hover:underline">Resend code</span>
{" · "}
<span className="text-foreground underline-offset-2 hover:underline">
Use a different email
</span>
</p>
</div>
</OnboardingCard>
);
}

View File

@ -0,0 +1,188 @@
// Shared presentational primitives for the onboarding flow. Ported from the
// prototype's hand-written CSS onto the repo's design tokens (semantic colors,
// Tailwind v4). No backend logic lives here — these are pure view pieces.
import type { ReactNode } from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ConnectorOption } from "./onboarding-data";
/**
* The centered card frame every onboarding step sits in.
*
* `translucent` renders the panel at 95% so an animated backdrop (the orbiting
* paperclip on the auth + welcome screens) reads through it, per the design.
*/
export function OnboardingCard({
children,
className,
translucent,
}: {
children: ReactNode;
className?: string;
translucent?: boolean;
}) {
return (
<div
className={cn(
"w-(--sz-560px) max-w-full rounded-xl border border-border px-8 py-10 sm:px-10 sm:py-11",
translucent ? "bg-card/95" : "bg-card",
className,
)}
>
{children}
</div>
);
}
/** Display heading + supporting lede, ported from the prototype's hero type. */
export function OnboardingHeading({
title,
lede,
center,
}: {
title: ReactNode;
lede?: ReactNode;
center?: boolean;
}) {
return (
<div className={cn("space-y-2", center && "text-center")}>
<h1 className="text-4xl font-semibold tracking-tight text-foreground">{title}</h1>
{lede ? (
<p className="text-base leading-relaxed text-muted-foreground">{lede}</p>
) : null}
</div>
);
}
/** Segmented progress bar with "Step N of M" meta. `total` defaults to 3. */
export function Stepper({ step, total = 3 }: { step: number; total?: number }) {
return (
<div className="mb-7 flex flex-col gap-3.5">
<div className="flex items-center gap-2">
{Array.from({ length: total }, (_, i) => i + 1).map((s) => (
<span
key={s}
className={cn(
"h-(--sz-3px) flex-1 rounded-full transition-colors",
s <= step ? "bg-foreground" : "bg-border",
)}
/>
))}
</div>
<span className="text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
Step {step} of {total}
</span>
</div>
);
}
/** Selectable pill chip (mission suggestions). */
export function Chip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full border px-3.5 py-2 text-sm transition-colors",
active
? "border-foreground bg-accent text-foreground"
: "border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
/** Large selectable option card (first-task choices). */
export function ChoiceCard({
icon,
title,
description,
selected,
onClick,
}: {
icon: ReactNode;
title: string;
description: string;
selected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-start gap-3.5 rounded-md border bg-muted/30 p-4 text-left transition-all",
selected
? "border-foreground ring-(length:--rad-3) ring-foreground/10"
: "border-border hover:border-muted-foreground",
)}
>
<span className="grid size-9 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
{icon}
</span>
<span className="flex-1">
<span className="block text-sm font-semibold text-foreground">{title}</span>
<span className="mt-1 block text-(length:--text-compact) leading-snug text-muted-foreground">
{description}
</span>
</span>
<Check
className={cn(
"size-5 shrink-0 text-green-500 transition-opacity",
selected ? "opacity-100" : "opacity-0",
)}
/>
</button>
);
}
/** A single connector row with a colored glyph and connect/connected toggle. */
export function ConnectorRow({
connector,
connected,
onToggle,
}: {
connector: ConnectorOption;
connected: boolean;
onToggle: () => void;
}) {
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3.5 py-3">
<span
className="grid size-(--sz-30px) shrink-0 place-items-center rounded-md text-(length:--text-compact) font-bold text-white"
style={{
background: connector.color,
border: connector.border ? "1px solid var(--border)" : undefined,
}}
>
{connector.glyph}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">{connector.name}</span>
<span className="block text-xs text-muted-foreground">{connector.description}</span>
</span>
<button
type="button"
onClick={onToggle}
className={cn(
"rounded-full border px-3.5 py-2 text-(length:--text-compact) font-medium transition-colors",
connected
? "cursor-default border-border text-muted-foreground"
: "border-border text-foreground hover:border-muted-foreground",
)}
>
{connected ? "Connected" : "Connect"}
</button>
</div>
);
}

View File

@ -0,0 +1,42 @@
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 (
<MotionConfig reducedMotion="user">
<div className="fixed inset-0 z-50 flex flex-col overflow-y-auto bg-background">
<button
onClick={onClose}
className="absolute left-4 top-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 transition-colors hover:text-foreground"
aria-label="Close onboarding"
>
<X className="size-5" />
</button>
<div className="flex min-h-full w-full items-center justify-center px-5 py-20">
<AnimatePresence mode="wait">
<motion.div key={stepKey} className="flex w-full justify-center" {...stepMotion}>
{children}
</motion.div>
</AnimatePresence>
</div>
</div>
</MotionConfig>
);
}

View File

@ -0,0 +1,335 @@
// 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<HTMLDivElement | null>(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 <div ref={hostRef} className={className} aria-hidden="true" />;
}
export default PaperclipOrbit3D;

View File

@ -0,0 +1,79 @@
// 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<string, string> = {
"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(),
};
}
}

View File

@ -0,0 +1,49 @@
// 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
// <MotionConfig reducedMotion="user"> 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 },
},
};

View File

@ -0,0 +1,144 @@
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 (
<OnboardingCard>
<Stepper step={step} total={total} />
<div className="space-y-6">
{/* 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. */}
<div className="flex flex-col items-center gap-2">
<motion.div {...capsuleHeroMotion} exit={capsuleHandoffExit}>
<AgentCapsule state="configured" gradient={5} glow="blue" size="md" />
</motion.div>
<AgentPreview agentName={agentName} agentRole={agentRole} />
</div>
<OnboardingHeading
title="Connect a model"
lede="What model would you like your first agent to use? You can choose different models when creating additional agents."
center
/>
<div className="grid grid-cols-2 gap-2">
{recommended.map((opt) => {
const selected = adapterType === opt.type;
return (
<button
key={opt.type}
type="button"
onClick={() => onAdapterChange(opt.type)}
className={cn(
"relative flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
selected
? "border-foreground bg-accent"
: "border-border hover:border-muted-foreground",
)}
>
<span className="absolute -top-1.5 right-1.5 rounded-full bg-green-500 px-1.5 py-0.5 text-(length:--text-nano) font-semibold leading-none text-white">
Recommended
</span>
<opt.icon className="size-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-(length:--text-nano) text-muted-foreground">
{opt.description}
</span>
</button>
);
})}
</div>
{additional.length > 0 ? (
<div className="flex flex-col gap-2">
<span className="text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
Additional models
</span>
<Select value={additionalValue} onValueChange={onAdapterChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose another model…" />
</SelectTrigger>
<SelectContent>
{additional.map((opt) => (
<SelectItem key={opt.type} value={opt.type}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<FooterNav
onBack={onBack}
primaryLabel="Connect now"
primaryDisabled={!adapterType}
loading={loading}
loadingLabel="Bringing to life..."
onPrimary={onNext}
/>
</div>
</OnboardingCard>
);
}

View File

@ -0,0 +1,102 @@
import { motion } from "motion/react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AgentCapsule } from "../../AgentCapsule";
import { ROLE_OPTIONS } from "../onboarding-data";
import { OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
import { FooterNav } from "../FooterNav";
import { AgentPreview } from "../AgentPreview";
import { capsuleHandoffExit, capsuleMotion } from "../onboarding-motion";
/** Create-your-first-agent step: role select + optional name, with the capsule. */
export function AgentStep({
agentRole,
agentName,
onRoleChange,
onNameChange,
onBack,
onNext,
loading,
step,
total,
primaryLabel = "Create",
loadingLabel = "Creating...",
}: {
agentRole: string;
agentName: string;
onRoleChange: (value: string) => void;
onNameChange: (value: string) => void;
onBack: () => void;
onNext: () => void;
loading?: boolean;
step: number;
total?: number;
/** CTA label — cloud hires here ("Create"); local advances to the adapter step ("Next"). */
primaryLabel?: string;
loadingLabel?: string;
}) {
const previewVisible = Boolean(agentName || agentRole);
return (
<OnboardingCard>
<Stepper step={step} total={total} />
<div className="space-y-6">
<div className="flex flex-col items-center gap-2">
<motion.div {...capsuleMotion} exit={capsuleHandoffExit}>
<AgentCapsule
state={previewVisible ? "configured" : "slot"}
strokeDraw
gradient={5}
glow="blue"
size="md"
/>
</motion.div>
<AgentPreview agentName={agentName} agentRole={agentRole} />
</div>
<OnboardingHeading title="Create your first agent" center />
<div className="mx-auto flex w-full max-w-(--sz-320px) flex-col gap-6">
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-agent-role">Role</Label>
<Select value={agentRole || undefined} onValueChange={onRoleChange}>
<SelectTrigger id="onboarding-agent-role" className="w-full">
<SelectValue placeholder="Select a role…" />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-agent-name">
Name <span className="font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="onboarding-agent-name"
placeholder="Name"
value={agentName}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
</div>
<FooterNav
onBack={onBack}
primaryLabel={primaryLabel}
primaryDisabled={!agentRole.trim()}
loading={loading}
loadingLabel={loadingLabel}
onPrimary={onNext}
/>
</div>
</OnboardingCard>
);
}

View File

@ -0,0 +1,87 @@
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { MISSION_CHIPS } from "../onboarding-data";
import { Chip, OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
import { FooterNav } from "../FooterNav";
/** Company name + mission step. */
export function CompanyStep({
companyName,
onCompanyNameChange,
mission,
onMissionChange,
onBack,
onNext,
loading,
step,
total,
}: {
companyName: string;
onCompanyNameChange: (value: string) => void;
mission: string;
onMissionChange: (value: string) => void;
onBack: () => void;
onNext: () => void;
loading?: boolean;
step: number;
total?: number;
}) {
const [activeChip, setActiveChip] = useState<string | null>(null);
return (
<OnboardingCard>
<Stepper step={step} total={total} />
<div className="space-y-6">
<OnboardingHeading
title="What is the name of your company or team?"
lede="This will be the name of your Paperclip organization — choose something your team will recognize."
/>
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-company-name">Name</Label>
<Input
id="onboarding-company-name"
placeholder="e.g. Northwind Labs"
value={companyName}
onChange={(e) => onCompanyNameChange(e.target.value)}
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-mission">What is the mission?</Label>
<Textarea
id="onboarding-mission"
className="min-h-(--sz-88px)"
placeholder="This could be your company mission, team goal, or desired outcome."
value={mission}
onChange={(e) => {
onMissionChange(e.target.value);
setActiveChip(null);
}}
/>
</div>
<div className="flex flex-wrap gap-2">
{MISSION_CHIPS.map((chip) => (
<Chip
key={chip}
label={chip}
active={activeChip === chip}
onClick={() => {
onMissionChange(chip);
setActiveChip(chip);
}}
/>
))}
</div>
<FooterNav
onBack={onBack}
primaryLabel="Next"
primaryDisabled={!companyName.trim() || !mission.trim()}
loading={loading}
loadingLabel="Creating..."
onPrimary={onNext}
/>
</div>
</OnboardingCard>
);
}

View File

@ -0,0 +1,62 @@
import { ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { OnboardingCard, OnboardingHeading } from "../OnboardingPrimitives";
/**
* Local-flow only: a gentle, OPTIONAL email ask before the numbered steps, with
* an explicit privacy assurance. Both Continue and Skip advance; the email is
* collected into container state (persistence is out of scope for now).
*/
export function EmailStep({
email,
onEmailChange,
onContinue,
onSkip,
}: {
email: string;
onEmailChange: (value: string) => void;
onContinue: () => void;
onSkip: () => void;
}) {
return (
<OnboardingCard>
<div className="space-y-6">
<OnboardingHeading
title="Want product updates?"
lede="Leave an email and we'll let you know about meaningful releases. Totally optional."
center
/>
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-email">
Email <span className="font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="onboarding-email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
autoFocus
/>
</div>
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3.5 py-3 text-xs leading-relaxed text-muted-foreground">
<ShieldCheck className="mt-px size-4 shrink-0 text-foreground/70" />
<span>
We'll never use this for marketing or share it with third parties — it's only for
the occasional product update.
</span>
</div>
<div className="flex flex-col gap-2">
<Button size="lg" className="w-full rounded-full" onClick={onContinue}>
Continue
</Button>
<Button variant="ghost" size="sm" className="rounded-full" onClick={onSkip}>
Skip for now
</Button>
</div>
</div>
</OnboardingCard>
);
}

View File

@ -0,0 +1,70 @@
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 (
<div className="flex flex-col items-center gap-9">
<h1 className="text-center text-4xl font-semibold leading-10 tracking-tight text-foreground">
Welcome to Paperclip!
</h1>
<div className="flex flex-wrap items-start justify-center gap-6">
<StartOption
icon={<PlusCircle className="size-11" strokeWidth={1.2} />}
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}
/>
<StartOption
icon={<Mail className="size-11" strokeWidth={1.2} />}
title="Join an existing company or team"
description="Have an invite? Enter your team's join code to come aboard."
onClick={() => {}}
disabled
/>
</div>
</div>
);
}
/** A square choice tile: icon, title and description all inside the card. */
function StartOption({
icon,
title,
description,
onClick,
disabled,
}: {
icon: React.ReactNode;
title: string;
description: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"flex h-(--sz-360px) w-(--sz-360px) max-w-full flex-col items-center justify-center gap-6 rounded-2xl border border-border bg-card/95 px-6 text-center transition-colors",
// Both tiles render identically per the comp — the stubbed "join" tile
// is inert (disabled) but must NOT be dimmed, or it reads as broken and
// lets the backdrop bleed through it.
"hover:border-muted-foreground disabled:hover:border-border",
)}
>
<span className="text-muted-foreground">{icon}</span>
<span className="flex w-72 max-w-full flex-col gap-2">
<span className="text-lg font-semibold leading-7 text-foreground">{title}</span>
<span className="text-sm leading-relaxed text-muted-foreground">{description}</span>
</span>
</button>
);
}

View File

@ -0,0 +1,131 @@
import { AnimatePresence, motion } from "motion/react";
import { Building2, Pencil, UserPlus } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { AgentCapsule } from "../../AgentCapsule";
import type { FirstTaskChoice } from "../onboarding-data";
import { ChoiceCard, OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
import { FooterNav } from "../FooterNav";
import { AgentPreview } from "../AgentPreview";
import { capsuleHeroMotion, STEP_EASE } from "../onboarding-motion";
/** Assign-the-first-task step: three choice cards + a custom-task reveal. */
export function TaskStep({
agentName,
agentRole,
taskChoice,
onSelectChoice,
customTask,
onCustomTaskChange,
onBack,
onGetStarted,
loading,
error,
step,
total,
}: {
agentName: string;
agentRole: string;
taskChoice: FirstTaskChoice | null;
onSelectChoice: (choice: FirstTaskChoice) => void;
customTask: string;
onCustomTaskChange: (value: string) => void;
onBack: () => void;
onGetStarted: () => void;
loading?: boolean;
error?: string | null;
step: number;
total?: number;
}) {
// Sentence-initial in the lede, so the fallback is capitalized.
const agentLabel = agentName || agentRole || "Your agent";
return (
<OnboardingCard>
<Stepper step={step} total={total} />
<div className="space-y-6">
<div className="flex flex-col items-center gap-2">
<motion.div {...capsuleHeroMotion}>
<AgentCapsule
state="online"
gradient={5}
glow="blue"
size="md"
// "Coming alive" (radial fill + outline carryover-fade) runs 2×
// the default reveal duration here for a hero beat.
style={{ ["--agent-cap-reveal-duration"]: "2.8s" } as React.CSSProperties}
/>
</motion.div>
<AgentPreview agentName={agentName} agentRole={agentRole} />
</div>
<OnboardingHeading
title="Assign your agent a first task"
lede={<>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. */}
<div>
<div className="flex flex-col gap-3">
<ChoiceCard
icon={<UserPlus className="size-5" />}
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")}
/>
<ChoiceCard
icon={<Building2 className="size-5" />}
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")}
/>
<ChoiceCard
icon={<Pencil className="size-5" />}
title="Write your own task…"
description="Describe anything else you want done first."
selected={taskChoice === "custom"}
onClick={() => onSelectChoice("custom")}
/>
</div>
<AnimatePresence initial={false}>
{taskChoice === "custom" && (
<motion.div
key="custom-task-input"
initial={{ height: 0, opacity: 0, overflow: "hidden" }}
animate={{
height: "auto",
opacity: 1,
transitionEnd: { overflow: "visible" },
}}
exit={{ height: 0, opacity: 0, overflow: "hidden" }}
transition={{ duration: 0.3, ease: STEP_EASE }}
>
<div className="pt-3">
<Textarea
autoFocus
className="min-h-(--sz-88px)"
placeholder="Describe the first task…"
value={customTask}
onChange={(e) => onCustomTaskChange(e.target.value)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<FooterNav
onBack={onBack}
primaryLabel="Get started"
primaryDisabled={!taskChoice || (taskChoice === "custom" && !customTask.trim())}
loading={loading}
loadingLabel="Launching..."
onPrimary={onGetStarted}
/>
</div>
</OnboardingCard>
);
}

View File

@ -0,0 +1,162 @@
// @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(<Probe />);
});
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();
});
});

View File

@ -0,0 +1,483 @@
// 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<OnboardingAdapterConfigInput, "forceUnsetAnthropicApiKey">;
/** 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<CreatedOnboardingEntities>) {
const queryClient = useQueryClient();
const { setSelectedCompanyId } = useCompany();
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
initial?.createdCompanyId ?? null,
);
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<string | null>(
initial?.createdCompanyPrefix ?? null,
);
const [createdAgentId, setCreatedAgentId] = useState<string | null>(
initial?.createdAgentId ?? null,
);
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
initial?.createdCompanyGoalId ?? null,
);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(
initial?.createdProjectId ?? null,
);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(
initial?.createdIssueRef ?? null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [adapterEnvResult, setAdapterEnvResult] =
useState<AdapterEnvironmentTestResult | null>(null);
const [adapterEnvError, setAdapterEnvError] = useState<string | null>(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<string | null>(null);
function buildAdapterConfig(adapter: AdapterInput): Record<string, unknown> {
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, unknown>,
): 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<string, unknown>,
): Promise<AdapterEnvironmentTestResult | null> {
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<void> {
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<string, unknown>) }
: {};
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<typeof useOnboardingFlow>;

View File

@ -993,38 +993,57 @@
/* Agent capsule motif (PAP-119) "the capsule is the agent".
Three states realized in the AgentCapsule component:
- slot: dashed outline gently pulsing (an empty agent slot)
- slot: static dashed outline (an empty agent slot)
- configured: solid stroke (named/model picked, not yet live)
- 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%; }
- 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%); }
}
@keyframes agent-cap-online-pulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
box-shadow:
0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent),
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);
box-shadow:
0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent),
0 0 0 14px color-mix(in oklab, #22c55e 7%, 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;
}
.agent-cap-liquid {
animation: agent-cap-rise 1.4s cubic-bezier(0.16, 1, 0.3, 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;
}
.agent-cap-online {
@ -1035,11 +1054,15 @@
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);
box-shadow:
0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent),
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);
box-shadow:
0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent),
0 0 0 14px color-mix(in oklab, #2563eb 9%, transparent);
transform: scale(1.03);
}
}
@ -1055,7 +1078,6 @@
}
@media (prefers-reduced-motion: reduce) {
.agent-cap-slot,
.agent-cap-online,
.agent-cap-online-blue {
animation: none;
@ -1065,7 +1087,11 @@
}
.agent-cap-liquid {
animation: none;
height: 100%;
clip-path: none;
}
.agent-cap-stroke-carryover {
animation: none;
opacity: 0;
}
}
@ -2324,6 +2350,8 @@ 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

View File

@ -0,0 +1,50 @@
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<string, unknown>;
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<string, unknown> | 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<string, unknown> | undefined;
expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
});
});

View File

@ -0,0 +1,63 @@
// 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<string, unknown> {
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<string, unknown>) }
: {};
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
}
return config;
}

View File

@ -0,0 +1,16 @@
// 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.";

View File

@ -0,0 +1,112 @@
// 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=<name>` 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<Phase>(initialPhase);
const authVisible = phase === "account" || phase === "otp";
const exitTimer = useRef<number | undefined>(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 ? (
<LocalOnboardingFlow initialStep={flowStart as never} previewMock onClose={() => {}} />
) : (
<CloudOnboardingFlow initialStep={flowStart as never} previewMock onClose={() => {}} />
))}
<OnboardingAuthBackdrop visible={authVisible}>
{phase === "otp" ? (
<OtpScreen email="you@company.com" onContinue={finishAuth} />
) : (
<AccountScreen onContinue={() => setPhase("otp")} />
)}
</OnboardingAuthBackdrop>
</>
);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MemoryRouter>
<CompanyProvider>
<TooltipProvider>
<Preview />
</TooltipProvider>
</CompanyProvider>
</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
);

View File

@ -120,6 +120,15 @@ 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";
@ -253,6 +262,61 @@ 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<string | null>(MISSION_CHIPS[0] ?? null);
return (
<div className="flex flex-wrap gap-2">
{MISSION_CHIPS.map((chip) => (
<Chip key={chip} label={chip} active={active === chip} onClick={() => setActive(chip)} />
))}
</div>
);
}
function OnboardingChoiceShowcase() {
const [choice, setChoice] = useState<"hiring" | "strategy">("hiring");
return (
<div className="flex max-w-xl flex-col gap-3">
<ChoiceCard
icon={<Bot className="size-5" />}
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")}
/>
<ChoiceCard
icon={<ListTodo className="size-5" />}
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")}
/>
</div>
);
}
function OnboardingConnectorShowcase() {
const [connected, setConnected] = useState<string[]>([CONNECTORS[0]?.name ?? ""]);
return (
<div className="flex max-w-xl flex-col gap-3">
{CONNECTORS.slice(0, 2).map((c) => (
<ConnectorRow
key={c.name}
connector={c}
connected={connected.includes(c.name)}
onToggle={() =>
setConnected((prev) =>
prev.includes(c.name) ? prev.filter((n) => n !== c.name) : [...prev, c.name],
)
}
/>
))}
</div>
);
}
// 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() {
@ -432,6 +496,7 @@ export function DesignGuide() {
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip",
"AgentCapsule", "OnboardingCard", "Stepper", "Chip", "ChoiceCard", "ConnectorRow",
].map((name) => (
<Badge key={name} variant="ghost" className="font-mono text-(length:--text-nano)">
{name}
@ -766,6 +831,38 @@ export function DesignGuide() {
</SubSection>
</Section>
{/* ============================================================ */}
{/* ONBOARDING FLOW */}
{/* ============================================================ */}
<Section title="Onboarding Flow">
<p className="text-sm text-muted-foreground max-w-prose">
Primitives for the full-screen onboarding flow (
<code className="font-mono">components/onboarding/</code>). Bespoke surfaces keep
their prototype dimensions via verbatim size tokens (
<code className="font-mono">--sz-560px</code> card,{" "}
<code className="font-mono">--sz-320px</code> start tiles); type, fields, and focus
treatment come from the shared primitives and scale.
</p>
<SubSection title="Card frame + heading + stepper">
<OnboardingCard>
<Stepper step={2} />
<OnboardingHeading
title="Create your first agent"
lede="Display heading (text-4xl) with a supporting lede, inside the 560px card frame."
/>
</OnboardingCard>
</SubSection>
<SubSection title="Mission chips">
<OnboardingChipsShowcase />
</SubSection>
<SubSection title="Choice cards">
<OnboardingChoiceShowcase />
</SubSection>
<SubSection title="Connector rows">
<OnboardingConnectorShowcase />
</SubSection>
</Section>
{/* ============================================================ */}
{/* FORM ELEMENTS */}
{/* ============================================================ */}

View File

@ -28,7 +28,7 @@ import { EntityRow } from "@/components/EntityRow";
import { FilterBar, type FilterValue } from "@/components/FilterBar";
import { KanbanBoard } from "@/components/KanbanBoard";
import { LiveRunWidget } from "@/components/LiveRunWidget";
import { OnboardingWizard } from "@/components/OnboardingWizard";
import { CloudOnboardingFlow } from "@/components/onboarding/CloudOnboardingFlow";
import {
buildFileTree,
collectAllPaths,
@ -43,7 +43,6 @@ import { SwipeToArchive } from "@/components/SwipeToArchive";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useDialog } from "@/context/DialogContext";
import { queryKeys } from "@/lib/queryKeys";
import {
createIssue,
@ -324,19 +323,9 @@ function LiveRunWidgetStory({ empty = false, loading = false }: { empty?: boolea
);
}
function OpenOnboardingOnMount({ initialStep }: { initialStep: 1 | 2 }) {
const { openOnboarding } = useDialog();
const queryClient = useQueryClient();
useEffect(() => {
queryClient.setQueryData(queryKeys.agents.adapterModels(companyId, "claude_local"), [
{ id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ id: "claude-opus-4-1", label: "Claude Opus 4.1" },
]);
openOnboarding(initialStep === 1 ? { initialStep } : { initialStep, companyId });
}, [initialStep, openOnboarding, queryClient]);
return <OnboardingWizard />;
function OnboardingStepStory({ step }: { step: "company" | "agent" }) {
// previewMock keeps the flow backend-free for the story canvas.
return <CloudOnboardingFlow initialStep={step} previewMock />;
}
function PackageFileTreeDemo({ empty = false }: { empty?: boolean }) {
@ -705,14 +694,14 @@ export const LiveRunWidgetEmpty: Story = {
render: () => <LiveRunWidgetStory empty />,
};
export const OnboardingWizardCompanyStep: Story = {
name: "OnboardingWizard / Company Step",
render: () => <OpenOnboardingOnMount initialStep={1} />,
export const OnboardingFlowCompanyStep: Story = {
name: "Onboarding Flow / Company Step",
render: () => <OnboardingStepStory step="company" />,
};
export const OnboardingWizardAgentStep: Story = {
name: "OnboardingWizard / Agent Step",
render: () => <OpenOnboardingOnMount initialStep={2} />,
export const OnboardingFlowAgentStep: Story = {
name: "Onboarding Flow / Agent Step",
render: () => <OnboardingStepStory step="agent" />,
};
export const PackageFileTreePopulated: Story = {

View File

@ -11,6 +11,12 @@ export default defineConfig(({ mode }) => ({
plugins: [react(), tailwindcss()],
build: {
minify: "esbuild",
rollupOptions: {
input: {
main: path.resolve(__dirname, "index.html"),
"onboarding-preview": path.resolve(__dirname, "onboarding-preview.html"),
},
},
},
esbuild:
mode === "production"
@ -26,7 +32,9 @@ export default defineConfig(({ mode }) => ({
},
},
server: {
port: 5173,
// Harness-managed preview servers assign a port via the PORT env var;
// default stays 5173 for plain `vite` runs.
port: process.env.PORT ? Number(process.env.PORT) : 5173,
watch: createUiDevWatchOptions(process.cwd()),
proxy: apiProxy,
},