From 11e56654f8492f78ec56986e51be3d33f8fd6873 Mon Sep 17 00:00:00 2001 From: Tonio Date: Thu, 6 Aug 2026 23:35:05 -0700 Subject: [PATCH] feat(ui): port onboarding flow from prototype; add cloud + local variants (#10786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` 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 --- .claude/launch.json | 7 + .../references/component-index.md | 18 + .../e2e/conference-room-typing-intro.spec.ts | 66 +- tests/e2e/nux-phase4-screenshots.spec.ts | 141 +- tests/e2e/onboarding-flow.ts | 134 ++ tests/e2e/onboarding.spec.ts | 79 +- .../planning-mode-visual-verification.spec.ts | 53 +- ui/onboarding-preview.html | 22 + ui/package.json | 5 +- ui/src/components/AgentCapsule.tsx | 88 +- ui/src/components/OnboardingWizard.tsx | 1789 ----------------- .../OnboardingWizardVariant.test.tsx | 80 +- ui/src/components/OnboardingWizardVariant.tsx | 72 +- ui/src/components/onboarding/AgentPreview.tsx | 47 + .../onboarding/CloudOnboardingFlow.tsx | 165 ++ ui/src/components/onboarding/FooterNav.tsx | 50 + .../onboarding/GithubStarInterstitial.tsx | 44 + .../onboarding/LocalOnboardingFlow.tsx | 228 +++ .../onboarding/OnboardingAuthBackdrop.tsx | 102 + .../onboarding/OnboardingAuthScreens.tsx | 163 ++ .../onboarding/OnboardingPrimitives.tsx | 188 ++ .../onboarding/OnboardingScaffold.tsx | 42 + .../onboarding/PaperclipOrbit3D.tsx | 335 +++ .../components/onboarding/onboarding-data.ts | 79 + .../onboarding/onboarding-motion.ts | 49 + .../onboarding/steps/AdapterStep.tsx | 144 ++ .../components/onboarding/steps/AgentStep.tsx | 102 + .../onboarding/steps/CompanyStep.tsx | 87 + .../components/onboarding/steps/EmailStep.tsx | 62 + .../components/onboarding/steps/StartStep.tsx | 70 + .../components/onboarding/steps/TaskStep.tsx | 131 ++ ui/src/hooks/useOnboardingFlow.test.tsx | 162 ++ ui/src/hooks/useOnboardingFlow.ts | 483 +++++ ui/src/index.css | 72 +- ui/src/lib/onboarding-adapter-config.test.ts | 50 + ui/src/lib/onboarding-adapter-config.ts | 63 + ui/src/lib/onboarding-constants.ts | 16 + ui/src/onboarding-preview-main.tsx | 112 ++ ui/src/pages/DesignGuide.tsx | 97 + .../stories/data-viz-misc.stories.tsx | 31 +- ui/vite.config.ts | 10 +- 41 files changed, 3648 insertions(+), 2090 deletions(-) create mode 100644 tests/e2e/onboarding-flow.ts create mode 100644 ui/onboarding-preview.html delete mode 100644 ui/src/components/OnboardingWizard.tsx create mode 100644 ui/src/components/onboarding/AgentPreview.tsx create mode 100644 ui/src/components/onboarding/CloudOnboardingFlow.tsx create mode 100644 ui/src/components/onboarding/FooterNav.tsx create mode 100644 ui/src/components/onboarding/GithubStarInterstitial.tsx create mode 100644 ui/src/components/onboarding/LocalOnboardingFlow.tsx create mode 100644 ui/src/components/onboarding/OnboardingAuthBackdrop.tsx create mode 100644 ui/src/components/onboarding/OnboardingAuthScreens.tsx create mode 100644 ui/src/components/onboarding/OnboardingPrimitives.tsx create mode 100644 ui/src/components/onboarding/OnboardingScaffold.tsx create mode 100644 ui/src/components/onboarding/PaperclipOrbit3D.tsx create mode 100644 ui/src/components/onboarding/onboarding-data.ts create mode 100644 ui/src/components/onboarding/onboarding-motion.ts create mode 100644 ui/src/components/onboarding/steps/AdapterStep.tsx create mode 100644 ui/src/components/onboarding/steps/AgentStep.tsx create mode 100644 ui/src/components/onboarding/steps/CompanyStep.tsx create mode 100644 ui/src/components/onboarding/steps/EmailStep.tsx create mode 100644 ui/src/components/onboarding/steps/StartStep.tsx create mode 100644 ui/src/components/onboarding/steps/TaskStep.tsx create mode 100644 ui/src/hooks/useOnboardingFlow.test.tsx create mode 100644 ui/src/hooks/useOnboardingFlow.ts create mode 100644 ui/src/lib/onboarding-adapter-config.test.ts create mode 100644 ui/src/lib/onboarding-adapter-config.ts create mode 100644 ui/src/lib/onboarding-constants.ts create mode 100644 ui/src/onboarding-preview-main.tsx diff --git a/.claude/launch.json b/.claude/launch.json index 7cbc05c40a..1af7d4ce09 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -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 } ] } diff --git a/.claude/skills/design-guide/references/component-index.md b/.claude/skills/design-guide/references/component-index.md index 88e75178b3..93c43b1754 100644 --- a/.claude/skills/design-guide/references/component-index.md +++ b/.claude/skills/design-guide/references/component-index.md @@ -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?: 1–10`, `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 + + + + +``` + --- ## Layout Components diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts index e1a7096ed6..49364e6d8d 100644 --- a/tests/e2e/conference-room-typing-intro.spec.ts +++ b/tests/e2e/conference-room-typing-intro.spec.ts @@ -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 }); }); }); diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts index 4c3a3481be..9f788f7c68 100644 --- a/tests/e2e/nux-phase4-screenshots.spec.ts +++ b/tests/e2e/nux-phase4-screenshots.spec.ts @@ -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 1–3: - * - "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 (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); diff --git a/tests/e2e/onboarding-flow.ts b/tests/e2e/onboarding-flow.ts new file mode 100644 index 0000000000..49f4ffb60e --- /dev/null +++ b/tests/e2e/onboarding-flow.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + await startCloudOnboarding(page); + await completeCompanyStep(page, { companyName, mission }); + await completeAgentStep(page, { role }); + await completeTaskStep(page, { choice, customTask }); +} diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index eb46e76e50..f5a734d777 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -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); }); }); diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index 65be76312d..9692986b93 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -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(); diff --git a/ui/onboarding-preview.html b/ui/onboarding-preview.html new file mode 100644 index 0000000000..93a17b04b8 --- /dev/null +++ b/ui/onboarding-preview.html @@ -0,0 +1,22 @@ + + + + + + Onboarding Preview + + + + +
+ + + diff --git a/ui/package.json b/ui/package.json index ce25a72fb5..6788beded3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/AgentCapsule.tsx b/ui/src/components/AgentCapsule.tsx index 817a9dbb76..d671fb1ca0 100644 --- a/ui/src/components/AgentCapsule.tsx +++ b/ui/src/components/AgentCapsule.tsx @@ -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 dashed→solid 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` * (1–10). 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; + /** + * Slot→configured 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 (
- {/* 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. */}