From 3d366ba15ff17c8082e930b01dbf9f6f74503bb7 Mon Sep 17 00:00:00 2001 From: Tonio Date: Fri, 21 Aug 2026 16:41:53 -0700 Subject: [PATCH] Rebuild the onboarding agent arc on the prototype's step design (#11905) 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 > - The onboarding wizard in `ui/` hires that first agent. It runs three steps: create the agent, connect a model, and review > - A standalone prototype holds the agreed design for these steps. #10786 ported that prototype, but #11067 reverted it in full because the port deleted `OnboardingWizard.tsx` while four pull requests were editing that file > - Those four pull requests have since merged. The revert said the port can "re-land incrementally", and this is that re-land > - This pull request takes the presentational layer from the prototype only. It keeps master's wizard as the source of behaviour, so the eight onboarding fixes merged since the revert stay in place > - The benefit is that the three agent steps match the agreed design, and no merged fix is lost to get there ## Linked Issues or Issue Description Refs #10786 — the first attempt to land this design. Refs #11067 — the revert that asked for it to re-land in smaller steps. No public issue exists for the re-land. The problem is described below. **Subsystem affected** The `ui` package. The change touches the onboarding wizard, the agent capsule, and one Storybook story. It adds four small presentational components under `ui/src/components/onboarding/`. **Current behavior** The wizard's agent steps do not match the prototype. Each step shows a small heading beside an icon, above a form. The agent capsule sits below that heading and does not animate. The agent gets a name but no role, so every first agent is created as `ceo`. The wizard also shows a five-segment progress bar on these steps. A walker who enters on the agent step cannot reach the first two segments, so two of the five can never be filled. **Proposed behavior** The three steps use the prototype's card, its centred display heading, and its footer. One capsule sits above the heading and stays mounted across all three steps, so it reads as one object being built rather than three screens that each show their own. A three-segment strip counts these steps for a walker who enters on them. The full-length bar stays for a walker who starts at step one, so that count never restarts partway. The agent step gains a role. The options come from the agent role enum, not from the prototype's mock list. **Reason and benefit** The design is agreed and already built once. Re-landing it presentation-first keeps the behaviour that master gained after the revert. Sourcing roles from the enum matters. The prototype offers "Coder", which is not a valid role — the enum uses `engineer` — so a walker who picked it would fail validation at hire time. **Breaking changes** None. The wizard keeps its routes, its draft format, and its hire call. The draft gains one optional field, `agentRole`. A draft saved before this change loads without it and falls back to the default. ## What Changed - Add `ui/src/components/onboarding/`: `Stepper`, `OnboardingCard`, `OnboardingHeading`, `FooterNav`, `AgentPreview`, and shared motion constants - Rebuild wizard steps 3–5 on those parts: one card, the capsule above a centred heading, and one footer - Hold one `AgentCapsule` across the three steps. It springs in once, then morphs from dashed slot to traced outline to filled - Add `strokeDraw` to `AgentCapsule`. It traces the outline instead of cross-fading it. The dashed layer holds until the trace ends - Add a role select to the agent step. Choosing a role fills the name, unless the walker typed one - Show one progress indicator per run, not two - Label strip segments by destination, not by number - Add `motion` to the `ui` package - Add a Storybook story for the strip and the capsule states ## Verification Run the tests: ``` pnpm --filter @paperclipai/ui exec vitest run pnpm --filter @paperclipai/ui exec tsc -p tsconfig.json --noEmit ``` 4235 tests pass. The typecheck is clean. To see the steps, start the app and open `//onboarding` for a company that has a company-level goal. The wizard opens on the agent step. Step three requires a hire. Three absence assertions were checked by fault injection. Each one fails when the old behaviour returns: - put the step counter back, and the "shows no step counter" test fails - default `strokeDraw` to true, and the cross-fade test fails - restore the timer gate on the strip, and the indicator test fails ## Risks Low to medium. `motion` is one new dependency in `ui`. #11067 gave dependency weight as one of three reasons to revert #10786, so this branch carries the smallest set that works. `motion` drives the step transitions and the capsule choreography, and three files import it. An earlier revision of this branch also added `three` and `@types/three`. Both are removed. They existed for the 3D backdrop, which belongs to the auth and welcome screens rather than to these three steps, so nothing on this branch imported them. The role select changes what the wizard sends. Before this change every first agent was hired as `ceo`. Now the walker chooses. The values come from the enum, so the server accepts all of them. Steps 1 and 2 keep the older design. They do not run on the Cloud-first path, where the company already exists. ## Model Used Claude Opus 5 (`claude-opus-5`), with extended thinking, tool use, and code execution. Used for the code, the tests, and this description. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 5 --- .claude/launch.json | 7 +- .../e2e/conference-room-typing-intro.spec.ts | 9 +- tests/e2e/nux-phase4-screenshots.spec.ts | 2 +- tests/e2e/onboarding.spec.ts | 2 +- .../planning-mode-visual-verification.spec.ts | 11 +- .../docker-auth-onboarding.spec.ts | 10 +- ui/package.json | 1 + ui/src/components/AgentCapsule.test.tsx | 24 ++ ui/src/components/AgentCapsule.tsx | 73 ++++- .../components/OnboardingWizard.step.test.tsx | 82 +++++- ui/src/components/OnboardingWizard.test.tsx | 19 +- ui/src/components/OnboardingWizard.tsx | 273 ++++++++++++------ ui/src/components/onboarding/AgentPreview.tsx | 46 +++ ui/src/components/onboarding/FooterNav.tsx | 57 ++++ .../onboarding/OnboardingPrimitives.tsx | 45 +++ ui/src/components/onboarding/Stepper.test.ts | 32 ++ ui/src/components/onboarding/Stepper.tsx | 90 ++++++ .../onboarding/onboarding-motion.ts | 54 ++++ ui/src/lib/onboarding-agent-role.test.ts | 38 +++ ui/src/lib/onboarding-agent-role.ts | 37 +++ ui/storybook/.storybook/main.ts | 7 + .../stories/onboarding-agent-arc.stories.tsx | 64 ++++ 22 files changed, 865 insertions(+), 118 deletions(-) create mode 100644 ui/src/components/onboarding/AgentPreview.tsx create mode 100644 ui/src/components/onboarding/FooterNav.tsx create mode 100644 ui/src/components/onboarding/OnboardingPrimitives.tsx create mode 100644 ui/src/components/onboarding/Stepper.test.ts create mode 100644 ui/src/components/onboarding/Stepper.tsx create mode 100644 ui/src/components/onboarding/onboarding-motion.ts create mode 100644 ui/src/lib/onboarding-agent-role.test.ts create mode 100644 ui/src/lib/onboarding-agent-role.ts create mode 100644 ui/storybook/stories/onboarding-agent-arc.stories.tsx diff --git a/.claude/launch.json b/.claude/launch.json index 7cbc05c40a..6258bdc8cc 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,9 +4,12 @@ { "name": "paperclip", "runtimeExecutable": "/bin/sh", - "runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"], + "runtimeArgs": [ + "-c", + "TMPDIR=/tmp pnpm dev" + ], "port": 3108, - "autoPort": false + "autoPort": true } ] } diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts index 6d528bc99f..4bffe5f5eb 100644 --- a/tests/e2e/conference-room-typing-intro.spec.ts +++ b/tests/e2e/conference-room-typing-intro.spec.ts @@ -81,10 +81,11 @@ async function runOnboardingWizard(page: Page, companyName: string) { 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, - }); + // Step 3: the lead's role, then its name. The role gates "Next", and + // choosing one fills the name — so the walk only types here to override it. + await page.waitForSelector("#onboarding-agent-role", { timeout: 15_000 }); + await page.locator("#onboarding-agent-role").click(); + await page.getByRole("option", { name: "CEO", exact: true }).click(); await page.getByRole("button", { name: /^Next/ }).click(); // Step 4: adapter (claude_local default); heartbeat is intercepted. diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts index c872f5712b..d5b56929e4 100644 --- a/tests/e2e/nux-phase4-screenshots.spec.ts +++ b/tests/e2e/nux-phase4-screenshots.spec.ts @@ -83,7 +83,7 @@ test.describe("NUX Phase 4 visual QA", () => { // 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"]', { + await page.waitForSelector("#onboarding-agent-role", { timeout: 30_000, }); await page.screenshot({ path: shot("04-hire-team-lead.png") }); diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index a29078a724..ecd077d332 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -71,7 +71,7 @@ test.describe("Onboarding wizard", () => { // "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"]', { + await page.waitForSelector("#onboarding-agent-role", { timeout: 30_000, }); diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index 47053beb38..fb362d40e9 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -4,7 +4,8 @@ import { instrumentNavLog, } from "./helpers/onboarding-landing"; -const AGENT_NAME = "Chief of staff"; +/** The name the CEO role fills in — see AGENT_ROLE_LABELS. */ +const AGENT_NAME = "CEO"; const TASK_TITLE = "Paperclip onboarding"; test("captures planning mode UI for desktop and mobile", async ({ page }) => { @@ -64,8 +65,12 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { .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); + // The lead is no longer pre-named. Choosing a role fills the name from the + // role's label, which is also what gates "Next". + await page.waitForSelector("#onboarding-agent-role", { timeout: 30_000 }); + await page.locator("#onboarding-agent-role").click(); + await page.getByRole("option", { name: "CEO", exact: true }).click(); + await expect(page.locator("#onboarding-agent-name")).toHaveValue(AGENT_NAME); await page.getByRole("button", { name: /^Next/ }).click(); await page.getByRole("button", { name: /^Connect$/ }).click(); diff --git a/tests/release-smoke/docker-auth-onboarding.spec.ts b/tests/release-smoke/docker-auth-onboarding.spec.ts index dd02b0e40f..031d5862d9 100644 --- a/tests/release-smoke/docker-auth-onboarding.spec.ts +++ b/tests/release-smoke/docker-auth-onboarding.spec.ts @@ -61,10 +61,12 @@ test.describe("Docker authenticated onboarding smoke", () => { .fill(MISSION); await page.getByRole("button", { name: "Confirm mission" }).click(); - // Step 3: name the team lead. - const leadNameInput = page.locator('input[placeholder="Chief of staff"]'); - await expect(leadNameInput).toBeVisible({ timeout: 20_000 }); - await leadNameInput.fill(AGENT_NAME); + // Step 3: give the team lead a role, then a name. The role gates "Next". + const roleSelect = page.locator("#onboarding-agent-role"); + await expect(roleSelect).toBeVisible({ timeout: 20_000 }); + await roleSelect.click(); + await page.getByRole("option", { name: "CEO", exact: true }).click(); + await page.locator("#onboarding-agent-name").fill(AGENT_NAME); await page.getByRole("button", { name: "Next" }).click(); // Step 4: keep the default adapter and connect (hire) the lead. The diff --git a/ui/package.json b/ui/package.json index 6b626670ef..ae0c33915f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -60,6 +60,7 @@ "lexical": "0.49.0", "lucide-react": "^0.577.0", "mermaid": "^11.16.1", + "motion": "^12.42.2", "radix-ui": "^1.6.7", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/ui/src/components/AgentCapsule.test.tsx b/ui/src/components/AgentCapsule.test.tsx index 87eff2c090..87285eb032 100644 --- a/ui/src/components/AgentCapsule.test.tsx +++ b/ui/src/components/AgentCapsule.test.tsx @@ -90,4 +90,28 @@ describe("AgentCapsule", () => { expect(render().dataset.gradient).toBe("10"); expect(render().dataset.gradient).toBe("9"); }); + + it("traces the outline instead of cross-fading it when strokeDraw is set", () => { + // The cross-fade renders a bordered ; the trace renders an SVG rect + // animated from pathLength 0 to 1. + const cap = render(); + expect(cap.querySelector("svg rect")).not.toBeNull(); + expect(cap.querySelector(".agent-cap-stroke")).toBeNull(); + }); + + it("keeps the cross-fade when strokeDraw is not asked for", () => { + // Everywhere outside the onboarding arc the quieter default applies. + const cap = render(); + expect(cap.querySelector(".agent-cap-stroke")).not.toBeNull(); + expect(cap.querySelector("svg rect")).toBeNull(); + }); + + it("holds the dashed outline until the trace finishes", () => { + // Fading the dashed layer on the usual schedule would leave the capsule + // briefly outline-less in the middle of its own birth. + const cap = render(); + const dashed = cap.querySelector(".agent-cap-dash") as HTMLElement; + expect(dashed).not.toBeNull(); + expect(dashed.style.transitionDelay).not.toBe(""); + }); }); diff --git a/ui/src/components/AgentCapsule.tsx b/ui/src/components/AgentCapsule.tsx index 817a9dbb76..b696a3a9b6 100644 --- a/ui/src/components/AgentCapsule.tsx +++ b/ui/src/components/AgentCapsule.tsx @@ -1,3 +1,4 @@ +import { motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -60,10 +61,24 @@ export interface AgentCapsuleProps size?: AgentCapsuleSizePreset | { width: number; height: number }; /** Online-pulse colour (only applies in the `online` state). Defaults to `green`. */ glow?: AgentCapsuleGlow; + /** + * Render the 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 two layers cross-fading. + * + * This is the agent's "birth" moment in the onboarding wizard, where the + * customer has just named it and the capsule should read as being drawn into + * existence. Everywhere else the cross-fade is the right, quieter default. + */ + 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,12 +90,15 @@ export function AgentCapsule({ gradient = 1, size = "md", glow = "green", + strokeDraw = false, className, style, "aria-label": ariaLabel, ...rest }: AgentCapsuleProps) { const dims = typeof size === "string" ? SIZE_PRESETS[size] : size; + const reducedMotion = useReducedMotion(); + const drawn = state === "configured" || state === "online"; const idx = normalizeGradient(gradient); const fill = `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`; @@ -107,16 +125,53 @@ export function AgentCapsule({ "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", )} + // In strokeDraw mode the dashed outline stays put while the solid one + // is traced over it, and only then fades — otherwise the capsule would + // be briefly outline-less midway through its own birth. + 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. */} -