Rebuild the onboarding agent arc on the prototype's step design (#11905)

## 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 `/<PREFIX>/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 <noreply@anthropic.com>
This commit is contained in:
Tonio 2026-08-21 16:41:53 -07:00 committed by GitHub
parent 14027df09e
commit 3d366ba15f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 865 additions and 118 deletions

View File

@ -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
}
]
}

View File

@ -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.

View File

@ -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") });

View File

@ -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,
});

View File

@ -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();

View File

@ -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

View File

@ -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",

View File

@ -90,4 +90,28 @@ describe("AgentCapsule", () => {
expect(render(<AgentCapsule state="online" gradient={0} />).dataset.gradient).toBe("10");
expect(render(<AgentCapsule state="online" gradient={-1} />).dataset.gradient).toBe("9");
});
it("traces the outline instead of cross-fading it when strokeDraw is set", () => {
// The cross-fade renders a bordered <span>; the trace renders an SVG rect
// animated from pathLength 0 to 1.
const cap = render(<AgentCapsule state="configured" strokeDraw />);
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(<AgentCapsule state="configured" />);
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(<AgentCapsule state="configured" strokeDraw />);
const dashed = cap.querySelector(".agent-cap-dash") as HTMLElement;
expect(dashed).not.toBeNull();
expect(dashed.style.transitionDelay).not.toBe("");
});
});

View File

@ -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 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 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. */}
<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",
)}
/>
{/* Solid stroke agent configured, not yet live. Default: cross-fades in
on top of the dashed layer, then out as the liquid rises. strokeDraw:
an SVG outline traced around the perimeter (pathLength 01). */}
{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" : "opacity-0",
)}
/>
)}
{/* Brand-gradient liquid — rises to fill the capsule when online. */}
{state === "online" ? (
<span

View File

@ -105,7 +105,9 @@ function currentStep(): "mission" | "agent" | "closed" | "other" {
if (!body.querySelector("[role='dialog'], .fixed.inset-0")) return "closed";
const headings = [...body.querySelectorAll("h3")].map((h) => h.textContent);
if (headings.includes("Define your mission")) return "mission";
if (body.querySelector("input[placeholder='Chief of staff']")) return "agent";
// Keyed on the role control rather than the name field: the name is optional
// and starts empty, so its placeholder is the generic "Name".
if (body.querySelector("#onboarding-agent-role")) return "agent";
return "other";
}
@ -753,12 +755,37 @@ describe("OnboardingWizard — which step it lands on", () => {
expect(currentStep()).toBe("agent");
}
/**
* Choose a role, which the step now requires before it will advance it
* asks rather than assuming one. Driven by keyboard because the control is
* a Radix listbox: its pointer path needs `hasPointerCapture`, which jsdom
* does not implement, while its keyboard path does not.
*/
async function pickRole(label = "CEO") {
const trigger = document.getElementById("onboarding-agent-role")!;
await act(async () => {
trigger.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await settle();
const option = [...document.body.querySelectorAll('[role="option"]')].find(
(o) => o.textContent?.trim() === label,
) as HTMLElement | undefined;
expect(option).toBeDefined();
await act(async () => {
option!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
await settle();
}
it("seeds the lead agent's instructions with the mission it was never asked for", async () => {
// The regression this exists for. The agent step feeds
// `composeCeoInstructions` from the mission field, and a company entered
// here never types one — so the agent was hired knowing nothing of the
// mission the customer gave at signup, and nothing reported it.
await openOnAgentStep();
await pickRole();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
@ -790,6 +817,7 @@ describe("OnboardingWizard — which step it lands on", () => {
// nothing — the same "retained data is not an answer" rule the draft
// ownership gate follows.
await openOnAgentStep();
await pickRole();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
@ -847,6 +875,7 @@ describe("OnboardingWizard — which step it lands on", () => {
await rerender();
await settle();
expect(currentStep()).toBe("agent");
await pickRole();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
@ -868,6 +897,39 @@ describe("OnboardingWizard — which step it lands on", () => {
expect(file.content).toContain("Scale the marketplace");
});
it("hires the agent with the role the customer picked", async () => {
// The role was hardcoded to "ceo" before the role select existed. A
// dropdown that renders but does not reach the hire call would look
// entirely correct on screen and silently mis-file every agent.
await openOnAgentStep();
// Deliberately not the first option: "ceo" is what the hardcoded value
// was, so a test that picked it could not tell a wired dropdown from an
// ignored one.
await pickRole("Engineer");
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
)!;
await act(async () => {
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
const connect = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Connect"),
)!;
await act(async () => {
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalled();
const [, payload] = mockAgentsApi.hire.mock.calls.at(-1)!;
expect(payload.role).toBe("engineer");
// Picking a role also renamed the agent, since the field still held the
// name the wizard supplied.
expect(payload.name).toBe("Engineer");
});
it("does not offer a way back behind the step it entered on", async () => {
// Step 1 creates a company. A run that already holds one must not be
// able to walk into it, by the Back button or the progress bar.
@ -878,10 +940,20 @@ describe("OnboardingWizard — which step it lands on", () => {
);
expect(back).toBeUndefined();
const nameSegment = document.body.querySelector(
'[aria-label="Step 1"]',
) as HTMLButtonElement | null;
expect(nameSegment?.disabled).toBe(true);
// The progress strip's segments are the only jump controls on this
// screen. Entering here means there is nowhere behind to return to, so
// every one of them is inert — asserted over the whole set rather than
// one segment, since a single enabled one is the whole defect.
const segments = [...document.body.querySelectorAll("button")].filter((b) =>
["Create your first agent", "Connect a model", "Review"].includes(
b.getAttribute("aria-label") ?? "",
),
) as HTMLButtonElement[];
expect(segments).toHaveLength(3);
expect(segments.every((segment) => segment.disabled)).toBe(true);
// And company creation is genuinely unreachable, not merely unlinked.
expect(document.body.textContent).not.toContain("Name your company");
});
});
});

View File

@ -259,11 +259,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// (step 0, "Chief of staff").
expect(document.body.textContent).toContain("Create your first agent");
const nameInput = document.body.querySelector(
'input[placeholder="Chief of staff"]',
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value).toBe("Ops Lead");
// The run entered on the agent arc, so the arc strip is the progress
// indicator and counts 1-3 over the wizard's steps 3-5. Segments are
// labelled by destination: the wizard has its own numbering, and two
// controls both announcing "Step 1" would mean different things.
const currentStep = document.body.querySelector('[aria-current="step"]');
expect(currentStep?.getAttribute("aria-label")).toBe("Step 3");
expect(currentStep?.getAttribute("aria-label")).toBe("Create your first agent");
expect(document.body.textContent).toContain("Step 1 of 3");
await act(async () => {
root.unmount();
@ -344,7 +349,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expect(document.body.textContent).not.toBe("");
// The draft is not restored, because ownership cannot be verified...
const nameInput = document.body.querySelector(
'input[placeholder="Chief of staff"]',
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("Ops Lead");
// ...and not deleted either. The wizard is open in this harness, so the
@ -433,7 +438,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expect(document.body.textContent).not.toBe("");
// ...but the draft was not restored, because the list cannot be trusted.
const nameInput = document.body.querySelector(
'input[placeholder="Chief of staff"]',
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("Ops Lead");
@ -558,7 +563,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// Account A's agent name must not appear in account B's wizard.
expect(document.body.textContent).not.toContain("A's Lead");
const nameInput = document.body.querySelector(
'input[placeholder="Chief of staff"]',
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("A's Lead");
@ -613,7 +618,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expect(document.body.textContent).not.toBe("");
expect(document.body.textContent).not.toContain("A's Lead");
const nameInput = document.body.querySelector(
'input[placeholder="Chief of staff"]',
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("A's Lead");
@ -687,7 +692,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expect(document.body.textContent).not.toBe("");
expect(document.body.textContent).not.toContain("A's Lead");
const nameInput = document.body.querySelector(
'input[placeholder="Chief of staff"]',
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("A's Lead");

View File

@ -1,11 +1,17 @@
import { useEffect, useState, useMemo, useRef } from "react";
import type { CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MotionConfig, motion } from "motion/react";
import type {
AdapterEnvironmentTestResult,
AgentRole,
Environment,
InstanceSettings,
} from "@paperclipai/shared";
import { AGENT_ROLES, AGENT_ROLE_LABELS } from "@paperclipai/shared";
import { Label } from "./ui/label";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useLocation, useNavigate, useParams } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
@ -72,6 +78,12 @@ import {
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { FrontDoor } from "./FrontDoor";
import { AgentCapsule } from "./AgentCapsule";
import { AGENT_ARC_WIZARD_STEPS, Stepper, agentArcStepFor } from "./onboarding/Stepper";
import { AgentPreview } from "./onboarding/AgentPreview";
import { FooterNav } from "./onboarding/FooterNav";
import { OnboardingHeading } from "./onboarding/OnboardingPrimitives";
import { DEFAULT_AGENT_NAME, nextAgentNameForRole } from "../lib/onboarding-agent-role";
import { capsuleHeroMotion } from "./onboarding/onboarding-motion";
import { Badge } from "@/components/ui/badge";
import {
Building2,
@ -398,7 +410,12 @@ function OnboardingWizardInner({
const [q4, setQ4] = useState((saved?.q4 as string) ?? ""); // What would success look like?
// Step 2
const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "Chief of staff");
// Neither is defaulted. The prototype asks for a role before it will create
// anything, and leaves the name optional — a pre-filled "Chief of staff" is a
// choice made on the customer's behalf that they then have to notice and
// undo. Picking a role fills the name; see nextAgentNameForRole.
const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "");
const [agentRole, setAgentRole] = useState<AgentRole | "">((saved?.agentRole as AgentRole) ?? "");
const [adapterType, setAdapterType] = useState<AdapterType>((saved?.adapterType as AdapterType) ?? "claude_local");
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
const [model, setModel] = useState((saved?.model as string) ?? "");
@ -631,7 +648,7 @@ function OnboardingWizardInner({
if (!effectiveOnboardingOpen) return;
const state = {
step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
@ -639,7 +656,7 @@ function OnboardingWizardInner({
onboardingDraftStorage.write(JSON.stringify(state));
}, [
effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
@ -811,7 +828,11 @@ function OnboardingWizardInner({
setQ2("");
setQ3("");
setQ4("");
setAgentName("Chief of staff");
// Both cleared, matching the mount defaults: a reset that left a name
// behind without its role would put the walker back on a step whose CTA
// is disabled, next to a name nobody chose.
setAgentName("");
setAgentRole("");
setAdapterType("claude_local");
setModel("");
setCommand("");
@ -1232,9 +1253,12 @@ function OnboardingWizardInner({
}
}
if (!agentRole) return;
const hire = await agentsApi.hire(createdCompanyId, {
name: agentName.trim(),
role: "ceo",
// The name is optional; an agent that reaches here without one is
// named for the job it was hired to do rather than left blank.
name: agentName.trim() || AGENT_ROLE_LABELS[agentRole],
role: agentRole,
adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: buildNewAgentRuntimeConfig()
@ -1363,6 +1387,12 @@ function OnboardingWizardInner({
if (!effectiveOnboardingOpen) return null;
// The arc strip stands in for the full-length bar only when the run began on
// the arc — the Cloud-first path, where the company already exists and steps
// 1-2 never happen. A run that started at step 1 keeps one continuous count.
const isAgentArcStep = agentArcStepFor(step) !== null;
const showsAgentArcStepper = isAgentArcStep && entryStep >= 3;
const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId);
const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null);
@ -1408,9 +1438,25 @@ function OnboardingWizardInner({
step === 1 || step === 2 ? "md:w-1/2" : "md:w-full"
)}
>
<div className="w-full max-w-md mx-auto my-auto px-8 py-12 shrink-0">
<div
className={cn(
"mx-auto my-auto shrink-0",
// The arc sits in the prototype's card frame; the earlier steps
// keep the split-panel layout they were designed for. One
// element styled two ways, not two wrappers, so the step
// content below renders exactly once.
isAgentArcStep
? "w-(--sz-560px) max-w-full rounded-xl border border-border bg-card px-8 py-10 sm:px-10 sm:py-11"
: "w-full max-w-md px-8 py-12",
)}
>
{/* 5-segment progress bar (brand .wsteps/.wstep) segment N
filled once step N. Completed segments jump back. */}
filled once step N. Completed segments jump back.
Hidden for a run that entered on the agent arc: the arc strip
below counts that run's three steps, and showing both put two
progress bars on the same screen. A run that started at step 1
keeps this one throughout, so its count never restarts. */}
{!showsAgentArcStepper && (
<div className="flex items-center gap-1.5 mb-8">
{([1, 2, 3, 4, 5] as const).map((s) => {
const filled = step >= s;
@ -1436,75 +1482,85 @@ function OnboardingWizardInner({
);
})}
</div>
)}
{/* Persistent evolving capsule (steps 35): a single AgentCapsule
held in the same tree slot so React reuses the DOM node and the
morph reads as one capsule coming to life dashed slot
solid (configured) liquid fill + blue glow (online). */}
{/* The agent arc's progress strip. Numbered 13 over the wizard's
steps 35, because company creation already happened in Cloud
and the mission step is skipped when it did. */}
{showsAgentArcStepper && (
<Stepper
step={agentArcStepFor(step)!}
canJumpToStep={(target) =>
canJumpToOnboardingStep({
targetStep: AGENT_ARC_WIZARD_STEPS[target - 1]!,
currentStep: step,
entryStep,
})
}
onJumpToStep={(target) => setStep(AGENT_ARC_WIZARD_STEPS[target - 1]! as Step)}
/>
)}
{/* The hero, above the heading, as the prototype has it: one
AgentCapsule held in the same tree slot across steps 35, so
React reuses the DOM node and the morph reads as a single
capsule coming to life dashed slot traced outline
liquid fill. Moving between steps never replays the entrance. */}
{step >= 3 && step <= 5 && (
<div className="mb-6 space-y-4">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
{step === 5 ? (
<Check className="h-5 w-5 text-muted-foreground" />
) : (
<Bot className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div>
<h3 className="font-medium">
{step === 3
// reducedMotion="user" defers to the OS setting, so the hero
// arrives in place for anyone who asked for less movement. The
// token layer zeroes the CSS durations; this covers the JS half.
<MotionConfig reducedMotion="user">
{/* mb-6 continues the prototype's single rhythm past this
block: it groups the hero and heading, and the step's own
controls sit a step below on the same spacing. */}
<div className="mb-6 space-y-6">
<motion.div
initial={capsuleHeroMotion.initial}
animate={capsuleHeroMotion.animate}
transition={capsuleHeroMotion.transition}
className="flex flex-col items-center gap-2"
>
<AgentCapsule
state={step === 3 ? "slot" : step === 4 ? "configured" : "online"}
gradient={5}
glow="blue"
size="md"
// The arc is where the agent is born, so the
// slot→configured morph traces the outline on.
strokeDraw
/>
<AgentPreview
agentName={agentName}
agentRole={agentRole ? AGENT_ROLE_LABELS[agentRole] : ""}
/>
</motion.div>
<OnboardingHeading
center
title={
step === 3
? "Create your first agent"
: step === 4
? "Connect a model"
: "Review"}
</h3>
<p className="text-xs text-muted-foreground">
{step === 3 ? (
: "Review"
}
// The agent step carries no lede, as the prototype has it:
// the capsule and the heading say what this is, and a
// sentence restating it only pushes the fields down.
lede={
step === 3 ? undefined : step === 4 ? (
<>
They'll help drive{" "}
<span className="font-medium text-foreground">{companyName}</span>{" "}
toward its mission. We default to{" "}
<span className="font-medium text-foreground">Chief of staff</span>.
Rename it to anything you like.
What model would you like your first agent to use? You can
choose different models when creating additional agents.
</>
) : step === 4 ? (
<>Pick the adapter and model your lead will run on, then check the environment.</>
) : (
<>Your first agent is online and ready to work.</>
)}
</p>
</div>
</div>
<div
className={cn(
"flex flex-col items-center py-1 text-center",
step === 5 ? "mt-8 gap-2.5" : "gap-1.5"
)}
>
<AgentCapsule
state={step === 3 ? "slot" : step === 4 ? "configured" : "online"}
gradient={5}
glow="blue"
size="md"
)
}
/>
{step !== 3 && (
<p
className={cn(
"text-muted-foreground",
step === 5 ? "text-sm" : "text-(length:--text-micro)"
)}
>
{step === 4 ? (
"your team lead, taking shape"
) : (
<span className="font-medium text-foreground">{agentName}</span>
)}
</p>
)}
</div>
</div>
</MotionConfig>
)}
{/* Step content */}
@ -1834,25 +1890,47 @@ function OnboardingWizardInner({
</div>
)}
{/* Step 3: Create your team lead — name only (capsule above) */}
{/* Step 3: role, then an optional name the prototype's field
pair, in its order and its widths. */}
{step === 3 && (
<div className="space-y-5">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="Chief of staff"
<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>
{/* Options come from the AgentRole enum, not the prototype's
mock list: four of that list's seven entries have no
equivalent here, and one ("Coder") would fail validation
at hire time. */}
<Select
value={agentRole || undefined}
onValueChange={(value) => {
const nextRole = value as AgentRole;
setAgentRole(nextRole);
setAgentName((current) =>
nextAgentNameForRole({ currentName: current, nextRole }),
);
}}
>
<SelectTrigger id="onboarding-agent-role" className="w-full">
<SelectValue placeholder="Select a role…" />
</SelectTrigger>
<SelectContent>
{AGENT_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{AGENT_ROLE_LABELS[role]}
</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) => setAgentName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && agentName.trim()) {
e.preventDefault();
setStep(4);
}
}}
autoFocus
/>
</div>
</div>
@ -2267,7 +2345,37 @@ function OnboardingWizardInner({
</div>
)}
{isAgentArcStep && (
<FooterNav
onBack={
canGoBackFromOnboardingStep({ currentStep: step, entryStep })
? () => setStep((step - 1) as Step)
: undefined
}
// The prototype's cloud flow hires on this step and calls the
// action "Create". Here the model step sits between, so this
// one advances — which is exactly the distinction the
// prototype's own local flow draws with "Next".
primaryLabel={step === 3 ? "Next" : step === 4 ? "Connect" : "Get started"}
loadingLabel={step === 4 ? "Connecting..." : "Launching..."}
loading={step === 3 ? false : loading}
primaryDisabled={
step === 3
? !agentRole
: step === 4
? loading || adapterEnvLoading || missionUnresolvedForHire
: loading || launchStateIncomplete
}
onPrimary={() => {
if (step === 3) setStep(4);
else if (step === 4) handleGiveHeartbeat();
else handleLaunchToDashboard();
}}
/>
)}
{/* Footer navigation */}
{!isAgentArcStep && (
<div className="flex items-center justify-between mt-8">
<div>
{canGoBackFromOnboardingStep({ currentStep: step, entryStep }) && (
@ -2355,6 +2463,7 @@ function OnboardingWizardInner({
)}
</div>
</div>
)}
</div>
</div>
)}

View File

@ -0,0 +1,46 @@
import { motion } from "motion/react";
import { PREVIEW_REVEAL_DURATION, STEP_EASE } from "./onboarding-motion";
/**
* Name/role preview under the capsule. Collapsed to zero height until an
* identity exists, so the step opens compact; on selection the block grows to
* full height the centered card re-centers each frame, so the capsule slides
* 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,57 @@
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "../ui/button";
/**
* Shared footer for the arc's step cards: a ghost pill "Back" and a primary
* pill CTA that shows a spinner and a loading label while its action runs.
*
* `onBack` is optional a run that entered on this step has nowhere behind it
* to return to, and an inert Back button reads as a dead control rather than a
* boundary.
*/
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-6">
{onBack ? (
// has-[>svg]:pr-4 gives "Back" room from the pill's right edge,
// overriding size="sm"'s symmetric padding on that 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>
) : (
<span />
)}
<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,45 @@
// Presentational primitives for the onboarding wizard's agent arc, ported from
// the onboarding prototype onto the repo's design tokens. No backend logic
// lives here — these are pure view pieces.
import type { ReactNode } from "react";
import { cn } from "../../lib/utils";
/** The centered card frame each step of the arc sits in. */
export function OnboardingCard({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"w-(--sz-560px) max-w-full rounded-xl border border-border bg-card px-8 py-10 sm:px-10 sm:py-11",
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>
);
}

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { AGENT_ARC_TOTAL_STEPS, agentArcStepFor } from "./Stepper";
describe("agentArcStepFor", () => {
it("numbers the arc from the agent step, not from the wizard's first step", () => {
// The wizard's step 3 is the customer's step 1: company creation happened
// in Cloud, and the mission step is skipped when it did.
expect(agentArcStepFor(3)).toBe(1);
expect(agentArcStepFor(4)).toBe(2);
expect(agentArcStepFor(5)).toBe(3);
});
it("has no position for steps outside the arc", () => {
// The front door and the company/mission steps are not part of the
// sequence the strip counts, so they must not render one.
for (const wizardStep of [0, 1, 2, 6]) {
expect(agentArcStepFor(wizardStep)).toBeNull();
}
});
it("never numbers a step past the total it advertises", () => {
// "Step 4 of 3" is the failure an arithmetic mapping produces the first
// time a step is added ahead of the arc.
for (const wizardStep of [-1, 0, 1, 2, 3, 4, 5, 6, 7, 99]) {
const position = agentArcStepFor(wizardStep);
if (position !== null) {
expect(position).toBeGreaterThanOrEqual(1);
expect(position).toBeLessThanOrEqual(AGENT_ARC_TOTAL_STEPS);
}
}
});
});

View File

@ -0,0 +1,90 @@
import { cn } from "../../lib/utils";
/**
* The agent arc create the agent, connect it, review is the part of the
* wizard a customer walks as a numbered sequence. Company creation happens in
* Cloud before the tenant is ever reached, so it is not one of these steps.
*/
export const AGENT_ARC_TOTAL_STEPS = 3;
/**
* What each segment goes to. These are the labels assistive tech reads, in
* place of a bare number: the wizard has its own step numbering, and two
* controls both announcing "Step 1" while meaning different steps is worse
* than no number at all. The visible "Step N of 3" line carries the count.
*/
export const AGENT_ARC_STEP_LABELS = [
"Create your first agent",
"Connect a model",
"Review",
] as const;
/** Wizard step numbers that make up the arc, in order. */
export const AGENT_ARC_WIZARD_STEPS = [3, 4, 5] as const;
/**
* Map a wizard step onto its position in the arc, or `null` when the step is
* outside it.
*
* The two numbering schemes exist for different reasons and must not be
* conflated: the wizard's own step numbers include entries the customer may
* never see (the front door, and the company/mission steps that are skipped
* when Cloud already created the company), while the strip counts only what
* this leg of the walk actually shows. Deriving one from the other with
* arithmetic would silently produce "Step 0 of 3" the first time a step is
* inserted ahead of the arc.
*/
export function agentArcStepFor(wizardStep: number): number | null {
const index = AGENT_ARC_WIZARD_STEPS.indexOf(wizardStep as (typeof AGENT_ARC_WIZARD_STEPS)[number]);
return index === -1 ? null : index + 1;
}
/**
* Segmented progress strip with a "Step N of M" label.
*
* Segments double as the way back to a step already completed, which is the
* affordance the wizard's full-length bar provides outside the arc. A segment
* the customer may not return to stays a disabled button rather than
* disappearing: the wizard can be entered partway in, and a strip that simply
* omitted the steps behind the entry point would misreport how far along they
* are.
*/
export function Stepper({
step,
total = AGENT_ARC_TOTAL_STEPS,
canJumpToStep,
onJumpToStep,
}: {
step: number;
total?: number;
canJumpToStep?: (target: number) => boolean;
onJumpToStep?: (target: number) => void;
}) {
return (
<div className="mb-7 flex flex-col gap-3.5">
<div className="flex items-center gap-2">
{Array.from({ length: total }, (_, index) => index + 1).map((segment) => {
const jumpable = Boolean(canJumpToStep?.(segment) && onJumpToStep);
return (
<button
key={segment}
type="button"
aria-label={AGENT_ARC_STEP_LABELS[segment - 1] ?? `Step ${segment}`}
aria-current={segment === step ? "step" : undefined}
disabled={!jumpable}
onClick={() => jumpable && onJumpToStep?.(segment)}
className={cn(
"h-(--sz-3px) flex-1 rounded-full transition-colors",
segment <= step ? "bg-foreground" : "bg-border",
jumpable ? "cursor-pointer" : "cursor-default",
)}
/>
);
})}
</div>
<span className="text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
Step {step} of {total}
</span>
</div>
);
}

View File

@ -0,0 +1,54 @@
// Shared motion constants for the onboarding wizard's agent arc (steps 35).
// Ported from the onboarding prototype so the capsule choreography reads the
// same here as it does there. Reduced motion is honoured at the token layer
// (ui/src/index.css collapses --motion-duration-* under the media query) and
// by <MotionConfig reducedMotion="user"> where these are consumed.
/** Step crossfade easing — the house signature curve, also used for in-step reveals. */
export const STEP_EASE = [0.16, 1, 0.3, 1] as const;
/** Per-step enter/exit crossfade for the keyed step container. */
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 },
};
/**
* The dashed slot's entrance on the agent step: fades and scales up from 50%
* about its own centre. Deliberately no y offset that would bias the growth
* upward and read as a drop-in rather than something forming in place.
*/
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 },
};
/** The name/role reveal: the label fade is staggered by 25% of this. */
export const PREVIEW_REVEAL_DURATION = 0.45;
/**
* The hand-off that makes the capsule read as one object across all three
* steps rather than three separate renders: it eases out with the departing
* step, then resurfaces on the next one, springing back to full size so it
* lands rather than snapping. The exit duration mirrors the step transition so
* the two 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: {
// A soft spring: slow enough that the scale-up is noticeable, damped
// enough that it still settles. The fade is 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,38 @@
import { describe, expect, it } from "vitest";
import { AGENT_ROLE_LABELS } from "@paperclipai/shared";
import { DEFAULT_AGENT_NAME, nextAgentNameForRole } from "./onboarding-agent-role";
describe("nextAgentNameForRole", () => {
it("fills the name from the role when the field is empty", () => {
expect(nextAgentNameForRole({ currentName: "", nextRole: "cto" })).toBe(
AGENT_ROLE_LABELS.cto,
);
});
it("replaces the default name the wizard supplied", () => {
expect(nextAgentNameForRole({ currentName: DEFAULT_AGENT_NAME, nextRole: "designer" })).toBe(
AGENT_ROLE_LABELS.designer,
);
});
it("replaces a label left by a previous role", () => {
// Picking CTO then CMO should leave "CMO", not "CTO".
expect(nextAgentNameForRole({ currentName: AGENT_ROLE_LABELS.cto, nextRole: "cmo" })).toBe(
AGENT_ROLE_LABELS.cmo,
);
});
it("keeps a name the customer typed", () => {
// The failure this prevents is silent: the field still holds a plausible
// name afterwards, so the loss is invisible until the agent is hired.
expect(nextAgentNameForRole({ currentName: "Ada", nextRole: "engineer" })).toBe("Ada");
});
it("keeps a typed name that only differs by surrounding space", () => {
expect(nextAgentNameForRole({ currentName: " Ada ", nextRole: "engineer" })).toBe(" Ada ");
});
it("treats a whitespace-only field as empty", () => {
expect(nextAgentNameForRole({ currentName: " ", nextRole: "qa" })).toBe(AGENT_ROLE_LABELS.qa);
});
});

View File

@ -0,0 +1,37 @@
import { AGENT_ROLE_LABELS, type AgentRole } from "@paperclipai/shared";
/**
* The name the wizard offers before the customer picks a role. It is a job
* title rather than a role label because it reads as a person on the very
* first screen where the agent appears.
*/
export const DEFAULT_AGENT_NAME = "Chief of staff";
/**
* Names the wizard put there itself, and may therefore replace. Anything the
* customer typed is theirs and survives a role change.
*
* The prototype's version of this step simply overwrote the name whenever the
* role changed, which is fine in a mock with no real input to lose. Here it
* would silently discard a name someone chose deliberately, and the loss is
* invisible: the field still has *a* plausible name in it afterwards.
*/
const WIZARD_SUPPLIED_NAMES: ReadonlySet<string> = new Set([
DEFAULT_AGENT_NAME,
...Object.values(AGENT_ROLE_LABELS),
]);
/**
* The name to show after a role change the new role's label when the field
* still holds something the wizard supplied, otherwise the customer's own text.
*/
export function nextAgentNameForRole(params: {
currentName: string;
nextRole: AgentRole;
}): string {
const current = params.currentName.trim();
if (current === "" || WIZARD_SUPPLIED_NAMES.has(current)) {
return AGENT_ROLE_LABELS[params.nextRole];
}
return params.currentName;
}

View File

@ -20,7 +20,14 @@ const config: StorybookConfig = {
viteFinal: async (baseConfig) =>
mergeConfig(baseConfig, {
plugins: [tailwindcss()],
optimizeDeps: { include: ["motion/react", "react", "react-dom"] },
resolve: {
// Storybook's core and the react-vite builder each resolve their own
// React under pnpm's strict tree. Any component that calls a hook from
// a third-party package — `motion`'s useReducedMotion, in the agent
// capsule — then gets a second copy and fails with "Invalid hook call".
// The app's own dev server hoists one React and never hit this.
dedupe: ["react", "react-dom"],
alias: {
"@": path.resolve(storybookConfigDir, "../../src"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/dist/Lexical.mjs"),

View File

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AgentCapsule } from "@/components/AgentCapsule";
import { Stepper } from "@/components/onboarding/Stepper";
/**
* The onboarding wizard's agent arc: create the agent, connect it, review.
* These are the three steps a customer walks inside the tenant company
* creation happens in Cloud before they arrive, which is why the strip counts
* to three rather than to the wizard's own step numbers.
*/
const meta = {
title: "Onboarding/Agent arc",
parameters: { layout: "centered" },
} satisfies Meta;
export default meta;
export const ProgressStrip: StoryObj = {
render: () => (
<div className="w-[420px] space-y-10">
{[1, 2, 3].map((step) => (
<Stepper key={step} step={step} />
))}
</div>
),
};
/**
* The capsule's three states, which the wizard holds in one tree slot so the
* morph reads as a single object coming to life rather than three renders.
*/
export const CapsuleStates: StoryObj = {
render: () => (
<div className="flex items-center gap-12">
{(["slot", "configured", "online"] as const).map((state) => (
<div key={state} className="flex flex-col items-center gap-3">
<AgentCapsule state={state} gradient={5} glow="blue" size="md" strokeDraw />
<span className="text-(length:--text-micro) uppercase tracking-widest text-muted-foreground">
{state}
</span>
</div>
))}
</div>
),
};
/**
* The same three states rendered with the default cross-fade, for comparison
* with the traced outline above.
*/
export const CapsuleStatesCrossfade: StoryObj = {
render: () => (
<div className="flex items-center gap-12">
{(["slot", "configured", "online"] as const).map((state) => (
<div key={state} className="flex flex-col items-center gap-3">
<AgentCapsule state={state} gradient={5} glow="blue" size="md" />
<span className="text-(length:--text-micro) uppercase tracking-widest text-muted-foreground">
{state}
</span>
</div>
))}
</div>
),
};