The agent, drawn as itself (#12274)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A new customer's first session ends in the tenant's agent arc:
create an agent, connect a model, review
> - Walking it turned up questions the arc had no business asking — a
role picker using a vocabulary the customer has not been given, a model
picker asking them to judge models they have not met — and chrome
restating what they had just watched happen
> - Each one costs a first-session customer attention at the exact
moment they are deciding what this product is
> - This pull request cuts the arc to what it must ask, and draws the
agent as itself so the arc has a visible subject
> - The benefit is three steps that each ask one thing, ending on an
agent that is visibly ready

## Linked Issues or Issue Description

No public issue exists. The changes come from walking the sign-up arc
end to end.

**What happened:**
The agent step asks for a role from a fixed enum before asking for a
name. The model step shows two "Recommended" badges (on both options),
an "Adapter type" eyebrow, and a model picker. The review step lists a
three-row checklist of work the customer just performed. The progress
strip is a full-width segmented bar.

**Expected behavior:**
The agent step asks for a name. The model step offers the two harnesses
and hides the rest behind advanced settings. The review step says the
agent is ready. The strip counts three discrete steps.

**Steps to reproduce:**
1. Sign up and enter the tenant wizard on the agent arc.
2. Observe the role select above the optional name field.
3. Continue to the model step: both options carry a "Recommended" badge,
and a model picker sits below.
4. Continue to review: a checklist restates the organization name,
agent, and model.

**Additional context:**
The brand pill assets (`pill-1-dormant.svg`, `pill-1-alive.svg`) are
transcribed verbatim into a component rather than approximated. The role
removal exposed a latent silent-failure path — see Risks.

## What Changed

- `PillGuy` renders the brand pill in two states; the arc holds one
instance, dormant through create and connect, alive on review.
- The agent step asks for a name only. The name is required; the role
picker is gone.
- `DEFAULT_AGENT_ROLE` (`general`) backs every onboarding hire, and
`agentRole` now defaults to it rather than empty.
- The model step drops both "Recommended" badges, the "Adapter type"
eyebrow, and the model picker; "More Agent Adapter Types" becomes
"Advanced settings"; the sub-line becomes "Paperclip works with your
existing subscription or API keys."
- The review step drops its checklist; the heading becomes "Let's get
started..." with "[name] is ready to work!".
- The progress strip renders three left-aligned dots at the previous
gap.
- Five e2e specs and both wizard unit suites migrate off
`#onboarding-agent-role`.

## Verification

Run the tenant suite:

```
cd ui && npx vitest run
```

- 4398 tests pass across 474 files; `npx tsc --noEmit` clean.
- Walked live in a local instance: agent step (dots, dormant pill, name
placeholder), model step (no badges/eyebrow/picker, "Advanced
settings"), review (pill alive, new copy, no checklist).
- The retargeted role test asserts the hire payload carries `role:
"general"` and the typed name — it is the test that catches the silent
failure below.

## Risks

- **A latent silent failure, now closed.** `handleGiveHeartbeat` returns
early when `agentRole` is empty. With the picker removed and no default,
Connect would have hired nobody and shown no error. The default closes
it; the guard stays for any future path that clears the role.
- **Behavioral change:** every onboarding hire is filed as `general`
rather than a chosen role. The role remains editable in the app.
- **Behavioral change:** the model is no longer chosen during
onboarding. Every adapter offered here resolves its own default in
`buildAdapterConfig`, and the model is changeable later.
- **Assets:** the pill carries its own gradient fills and does not
follow the theme. That is deliberate — the agent looks like itself on
either ground.

## Model Used

Claude Opus 5 (`claude-opus-5`) via Claude Code, with tool use and code
execution.

## 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
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] 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
- [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-26 21:39:55 -07:00 committed by GitHub
parent 24d639abad
commit eb86fcd498
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 481 additions and 350 deletions

View File

@ -73,16 +73,15 @@ async function runOnboardingWizard(page: Page, companyName: string) {
if (await frontDoor.count()) await frontDoor.first().click();
// Step 1: company name.
await page.getByPlaceholder("Acme Corp").fill(companyName);
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByPlaceholder("e.g. Northwind Labs").fill(companyName);
await page.getByRole("button", { name: /^Continue/ }).click();
// Step 1's "Next" creates the company; the mission step no longer runs.
// 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();
// Step 3: name the agent. The role picker is gone — the arc asks for a
// name and hires under the neutral `general` role.
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
await page.locator("#onboarding-agent-name").fill("Ada");
await page.getByRole("button", { name: /^Next/ }).click();
// Step 4: adapter (claude_local default); heartbeat is intercepted.

View File

@ -66,15 +66,15 @@ test.describe("NUX Phase 4 visual QA", () => {
await createCard.first().click();
}
await expect(
page.getByRole("heading", { name: "Name your organization" }),
page.getByRole("heading", { name: "What is the name of your organization?" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics");
await page.getByPlaceholder("e.g. Northwind Labs").fill("QA Robotics");
await page.screenshot({ path: shot("02-create-name.png") });
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByRole("button", { name: /^Continue/ }).click();
// Step 1's "Next" creates the company and goes straight to the team lead.
// The mission screenshot that sat here is gone with the step it captured.
await page.waitForSelector("#onboarding-agent-role", {
await page.waitForSelector("#onboarding-agent-name", {
timeout: 30_000,
});
await page.screenshot({ path: shot("04-hire-team-lead.png") });
@ -111,10 +111,10 @@ test.describe("NUX Phase 4 visual QA", () => {
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 organization" }),
page.getByRole("heading", { name: "What is the name of your organization?" }),
).toBeVisible({ timeout: 10_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics Grow");
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByPlaceholder("e.g. Northwind Labs").fill("QA Robotics Grow");
await page.getByRole("button", { name: /^Continue/ }).click();
await expect(
page.getByRole("heading", { name: /Tell us about your team/ }),
).toBeVisible({ timeout: 10_000 });

View File

@ -53,15 +53,15 @@ test.describe("Onboarding wizard", () => {
// Step 1 — Name your organization.
await expect(
page.getByRole("heading", { name: "Name your organization" }),
page.getByRole("heading", { name: "What is the name of your organization?" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByPlaceholder("e.g. Northwind Labs").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Continue/ }).click();
// Step 1's "Next" now creates the company and goes straight to the agent.
// The mission step used to sit between them and do the creating; onboarding
// no longer asks for the mission, which is collected later in the app.
await page.waitForSelector("#onboarding-agent-role", {
await page.waitForSelector("#onboarding-agent-name", {
timeout: 30_000,
});

View File

@ -54,24 +54,25 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const createCard = page.getByRole("button", { name: /Build a new company/ });
if (await createCard.count()) await createCard.first().click();
await expect(page.getByRole("heading", { name: "Name your organization" })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "What is the name of your organization?" })).toBeVisible({ timeout: 15_000 });
await page.locator('input[placeholder="Acme Corp"]').fill(companyName);
await page.getByRole("button", { name: /^Next/ }).click();
await page.locator('input[placeholder="e.g. Northwind Labs"]').fill(companyName);
await page.getByRole("button", { name: /^Continue/ }).click();
// Naming the company creates it and goes straight to the agent step.
// 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);
// The agent step asks for a name and nothing else; the name is what gates
// "Next", and the hire is filed under the neutral `general` role.
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
await page.locator("#onboarding-agent-name").fill(AGENT_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByRole("button", { name: /^Connect$/ }).click();
await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 });
// The review step names the agent rather than the step.
await expect(
page.getByRole("heading", { name: "Let's get started..." }),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Get started/ }).click();
// The wizard now drops the user straight onto the first task's detail page,
// and must not bounce through the dashboard (PAP-404).

View File

@ -99,6 +99,9 @@ import {
shouldRedirectCompanylessRouteToOnboarding,
} from "./lib/onboarding-route";
import { filterHiddenInstanceSettingsPath, normalizeRememberedInstanceSettingsPath } from "./lib/instance-settings";
import { useCloudInstance } from "./hooks/useCloudInstance";
import { cloudStackCreateUrl } from "./lib/cloudLinks";
import { navigateTopLevel } from "@/lib/browserNavigation";
const CompanyExport = lazy(() =>
import("./pages/CompanyExport").then((module) => ({ default: module.CompanyExport })),
@ -440,6 +443,9 @@ function legacyToolsRedirectTarget(tab?: string) {
export function OnboardingRoutePage() {
const { companies } = useCompany();
const { openOnboarding } = useDialogActions();
const { t } = useTranslation();
const cloudInstance = useCloudInstance();
const createStackUrl = cloudStackCreateUrl(cloudInstance?.cloudBaseUrl ?? null);
const { onboardingOpen, onboardingRouteDismissed } = useDialogState();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const matchedCompany = companyPrefix
@ -471,24 +477,39 @@ export function OnboardingRoutePage() {
<h1 className="text-xl font-semibold">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
<div className="mt-4">
<Button
onClick={() =>
matchedCompany
? openOnboarding({
// "Add another agent" to a company that already has its
// mission must not stop to ask for the mission again. An
// unsettled or failed lookup reads as "no mission" and
// costs the step, which the customer can pass - and the
// mission step now updates the existing goal rather than
// adding a second one.
initialStep: onboardingStepForCompany(),
companyId: matchedCompany.id,
})
: openOnboarding()
}
>
{matchedCompany ? "Add Agent" : "Start Onboarding"}
</Button>
{/* On a managed stack whose Cloud origin is unknown there is nowhere
to send this click: creation lives on Cloud, and in-app creation
is a 403 floor. A button that does nothing is worse than none, so
say why instead of rendering an inert control. */}
{!matchedCompany && cloudInstance && !createStackUrl ? (
<p className="text-sm text-muted-foreground">
{t("app.cloudCreateUnavailable", {
defaultValue:
"Organizations are created in Paperclip Cloud. This instance can't reach it right now — try again from your Cloud portfolio.",
})}
</p>
) : (
<Button
onClick={() =>
matchedCompany
? openOnboarding({
// "Add another agent" to a company that already has its
// mission must not stop to ask for the mission again. An
// unsettled or failed lookup reads as "no mission" and
// costs the step, which the customer can pass - and the
// mission step now updates the existing goal rather than
// adding a second one.
initialStep: onboardingStepForCompany(),
companyId: matchedCompany.id,
})
: cloudInstance && createStackUrl
? navigateTopLevel(createStackUrl)
: openOnboarding()
}
>
{matchedCompany ? "Add Agent" : "Start Onboarding"}
</Button>
)}
</div>
</div>
</div>
@ -558,6 +579,10 @@ function UnprefixedBoardRedirect() {
function NoCompaniesStartPage() {
const { openOnboarding } = useDialogActions();
const { t } = useTranslation();
// A managed stack with no visible companies is a loading or error state, not
// an invitation to create one in-app — creation lives on Cloud (403 floor).
const cloudInstance = useCloudInstance();
const createStackUrl = cloudStackCreateUrl(cloudInstance?.cloudBaseUrl ?? null);
return (
<div className="mx-auto max-w-xl py-10">
@ -569,9 +594,26 @@ function NoCompaniesStartPage() {
{t("app.noCompanies.description", { defaultValue: "Get started by creating a company." })}
</p>
<div className="mt-4">
<Button onClick={() => openOnboarding()}>
{t("app.noCompanies.newCompany", { defaultValue: "New Company" })}
</Button>
{/* Same as the onboarding route: no Cloud origin means nowhere to
send the click, and in-app creation is a 403 floor here. */}
{cloudInstance && !createStackUrl ? (
<p className="text-sm text-muted-foreground">
{t("app.cloudCreateUnavailable", {
defaultValue:
"Organizations are created in Paperclip Cloud. This instance can't reach it right now — try again from your Cloud portfolio.",
})}
</p>
) : (
<Button
onClick={() =>
cloudInstance && createStackUrl
? navigateTopLevel(createStackUrl)
: openOnboarding()
}
>
{t("app.noCompanies.newCompany", { defaultValue: "New Company" })}
</Button>
)}
</div>
</div>
</div>

View File

@ -105,9 +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";
// 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";
// Keyed on the name field, which is the agent step's only control now that
// the role picker is gone.
if (body.querySelector("#onboarding-agent-name")) return "agent";
return "other";
}
@ -634,7 +634,7 @@ describe("OnboardingWizard — which step it lands on", () => {
await settle();
await click(
[...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
(b) => b.textContent?.trim() === "Continue",
)!,
);
await settle();
@ -704,7 +704,7 @@ describe("OnboardingWizard — which step it lands on", () => {
setControlledValue(nameInput, "Initech");
await settle();
const next = [...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
(b) => b.textContent?.trim() === "Continue",
)!;
await act(async () => {
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -776,26 +776,18 @@ describe("OnboardingWizard — which step it lands on", () => {
}
/**
* 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.
* Name the agent. The role picker is gone the arc asks for a name and
* hires with the neutral `general` role so advancing from step 3 means
* putting something in the one field it has.
*/
async function pickRole(label = "CEO") {
const trigger = document.getElementById("onboarding-agent-role")!;
await act(async () => {
trigger.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
async function nameAgent(name = "Ada") {
const field = document.getElementById("onboarding-agent-name") as HTMLInputElement;
expect(field, "the agent step should render its name field").toBeTruthy();
setControlledValue(field, name);
// Settle twice: the hire is guarded on the company's goal lookup
// (`missionUnresolvedForHire`), and a Connect that fires before that
// query resolves is swallowed by the guard rather than failing loudly.
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();
}
@ -805,7 +797,7 @@ describe("OnboardingWizard — which step it lands on", () => {
// 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();
await nameAgent();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
@ -837,7 +829,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();
await nameAgent();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
@ -895,7 +887,7 @@ describe("OnboardingWizard — which step it lands on", () => {
await rerender();
await settle();
expect(currentStep()).toBe("agent");
await pickRole();
await nameAgent();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
@ -917,15 +909,13 @@ 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.
it("hires under the neutral role, with the name the customer typed", async () => {
// The arc stopped asking for a role, so every onboarding hire is filed
// as `general` — and the hire guard returns *silently* when the role is
// missing, which is exactly how removing the picker could have shipped a
// Connect button that hires nobody. This is the test that catches that.
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");
await nameAgent("Ada");
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
@ -944,10 +934,8 @@ describe("OnboardingWizard — which step it lands on", () => {
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");
expect(payload.role).toBe("general");
expect(payload.name).toBe("Ada");
});
it("does not offer a way back behind the step it entered on", async () => {

View File

@ -147,6 +147,16 @@ import { ONBOARDING_STORAGE_KEY, OnboardingWizard } from "./OnboardingWizard";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
/** React tracks input value on the DOM node; set it the way React will see. */
function setControlledValue(el: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)!.set!;
setter.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
@ -249,7 +259,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
it("keeps the grow path's questionnaire", async () => {
const { root } = await openStepOne("grow");
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Tell us about your team");
expect(mockCompaniesApi.create).not.toHaveBeenCalled();
@ -260,7 +270,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
it("skips it on the create path, creating the company on the way", async () => {
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => t.startsWith("Continue"));
expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Initech" });
expect(document.body.textContent).toContain("Create your first agent");
@ -278,20 +288,15 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// unrendered step cannot pass as an absence.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Create your first agent");
// Step 3 → 4 needs an agent name; choosing a role fills it.
const roleTrigger = document.body.querySelector("#onboarding-agent-role") as HTMLElement;
// Step 3 → 4 needs an agent name — the one field the step has now.
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
roleTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const ceo = [...document.body.querySelectorAll('[role="option"]')].find(
(o) => o.textContent?.trim() === "CEO",
) as HTMLElement;
await act(async () => {
ceo.dispatchEvent(new MouseEvent("click", { bubbles: true }));
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => t.startsWith("Next"));
@ -304,15 +309,76 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// the checklist that actually renders it — stopping at the model step
// would let a Mission regression pass unseen.
await clickByText((t) => t.startsWith("Connect"));
expect(document.body.textContent).toContain("Review");
expect(document.body.textContent).toContain("Organization name");
expect(document.body.textContent).toContain("Agent created");
expect(document.body.textContent).toContain("Model connected");
// The review step is the heading and the woken agent, nothing else: the
// checklist that restated the walk in three rows is gone, and with it
// the Mission row that could only render unchecked.
expect(document.body.textContent).toContain("Let's get started...");
expect(document.body.textContent).toContain("Ada is ready to work!");
expect(document.body.textContent).not.toContain("Organization name");
expect(document.body.textContent).not.toContain("Model connected");
expect(document.body.textContent).not.toContain("Mission");
await act(async () => root.unmount());
});
it("hires from a legacy draft that saved an empty role", async () => {
// `agentRole: ""` was this field's default before the arc stopped asking
// for a role, so every draft saved by an earlier build carries it. `??`
// would pass the empty string straight through to the hire's silent
// return — the same no-op the default exists to prevent, arriving
// through a restored draft instead of a fresh one.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, onboardingPath: "create", companyName: "Initech", agentRole: "" }),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([]);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root, queryClient } = render();
const renderTree = () =>
act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderTree();
await flushReact();
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
};
await clickText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickText((t) => t.startsWith("Next"));
await clickText((t) => t.startsWith("Connect"));
expect(mockAgentsApi.hire).toHaveBeenCalled();
// The mock is declared with no parameters, so index the call rather than
// destructuring a zero-length tuple type.
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
expect((hireArgs[1] as { role: string }).role).toBe("general");
await act(async () => root.unmount());
});
it("hires one agent when Connect fires twice in one breath", async () => {
// The Connect handler re-runs a cached failed probe now that "Test now"
// is gone — so two overlapping submissions could both pass the fresh
@ -327,17 +393,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
await clickByText((t) => t.startsWith("Next"));
const roleTrigger = document.body.querySelector("#onboarding-agent-role") as HTMLElement;
await clickByText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
roleTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const ceo = [...document.body.querySelectorAll('[role="option"]')].find(
(o) => o.textContent?.trim() === "CEO",
) as HTMLElement;
await act(async () => {
ceo.dispatchEvent(new MouseEvent("click", { bubbles: true }));
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => t.startsWith("Next"));
@ -370,7 +431,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const { root } = await openStepOne("create");
const nameInput = document.body.querySelector(
'input[placeholder="Acme Corp"]',
'input[placeholder="e.g. Northwind Labs"]',
) as HTMLInputElement;
await act(async () => {
nameInput.dispatchEvent(
@ -401,7 +462,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const { root } = await openStepOne("create");
const nameInput = document.body.querySelector(
'input[placeholder="Acme Corp"]',
'input[placeholder="e.g. Northwind Labs"]',
) as HTMLInputElement;
await act(async () => {
for (let i = 0; i < 4; i++) {
@ -425,12 +486,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// not the mission screen it never saw.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne("create");
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Create your first agent");
await clickByText((t) => t.includes("Back"));
expect(document.body.textContent).toContain("Name your organization");
expect(document.body.textContent).toContain("What is the name of your organization?");
expect(document.body.textContent).not.toContain("Define your mission");
await act(async () => root.unmount());

View File

@ -71,18 +71,19 @@ import {
resolveRouteOnboardingOptions,
} from "../lib/onboarding-route";
import { useCompanyMission } from "../hooks/useCompanyMission";
import { useCloudInstance } from "../hooks/useCloudInstance";
import {
isExistingCompanyMissionUnresolved,
planMissionPersistence,
} from "../lib/onboarding-mission";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { FrontDoor } from "./FrontDoor";
import { AgentCapsule } from "./AgentCapsule";
import { PillGuy } from "./onboarding/PillGuy";
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 { DEFAULT_AGENT_ROLE } from "../lib/onboarding-agent-role";
import { capsuleHeroMotion } from "./onboarding/onboarding-motion";
import { Badge } from "@/components/ui/badge";
import {
@ -339,6 +340,9 @@ function OnboardingWizardInner({
// kept first so a future move inside the route tree needs no change here.
const companyPrefix =
matchedCompanyPrefix ?? companyPrefixFromOnboardingPath(location.pathname);
// Managed stacks create organizations on Cloud, so the route below never
// resolves into the create wizard there — see resolveRouteOnboardingOptions.
const cloudInstance = useCloudInstance();
// Support opening the wizard from a route (e.g. /onboarding or an existing
// company's "add agent" entry point) in addition to the dialog context.
@ -362,6 +366,7 @@ function OnboardingWizardInner({
pathname: location.pathname,
companyPrefix,
companies,
cloudManaged: Boolean(cloudInstance),
});
const effectiveOnboardingOpen =
onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
@ -408,12 +413,24 @@ function OnboardingWizardInner({
const [q4, setQ4] = useState((saved?.q4 as string) ?? ""); // What would success look like?
// Step 2
// 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.
// The name is not defaulted: a pre-filled "Chief of staff" is a choice made
// on the customer's behalf that they then have to notice and undo. It is the
// step's only question, and its CTA gates on it.
const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "");
const [agentRole, setAgentRole] = useState<AgentRole | "">((saved?.agentRole as AgentRole) ?? "");
// Defaults to `general` rather than empty. The arc stopped asking for a role
// — a customer naming their first agent is describing what it does, not
// filing it — but the hire still needs one, and the guard below returns
// silently when it is missing. An unset role there would mean Connect
// appearing to work and hiring nobody.
const [agentRole, setAgentRole] = useState<AgentRole>(
// `||`, not `??`: the empty string was this field's default before the arc
// stopped asking for a role, so every draft saved by an earlier build holds
// `agentRole: ""`. `??` passes that straight through, and an empty role
// reaches the silent return in the hire — the exact failure the default
// exists to prevent, arriving through a restored draft instead of a fresh
// one.
(saved?.agentRole as AgentRole) || DEFAULT_AGENT_ROLE,
);
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) ?? "");
@ -837,11 +854,10 @@ function OnboardingWizardInner({
setQ2("");
setQ3("");
setQ4("");
// 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.
// Back to the mount defaults: an empty name (the step's only question, and
// what its CTA gates on) and the neutral role every onboarding hire uses.
setAgentName("");
setAgentRole("");
setAgentRole(DEFAULT_AGENT_ROLE);
setAdapterType("claude_local");
setModel("");
setCommand("");
@ -1318,6 +1334,9 @@ function OnboardingWizardInner({
}
}
// `agentRole` always holds a value now (see its default), so this is a
// type narrowing rather than a gate — but it stays, because a future
// path that clears the role must not reach a hire that silently no-ops.
if (!agentRole) return;
const hire = await agentsApi.hire(createdCompanyId, {
// The name is optional; an agent that reaches here without one is
@ -1530,7 +1549,7 @@ function OnboardingWizardInner({
<div
className={cn(
"w-full flex flex-col overflow-y-auto transition-(--tp-width) duration-500 ease-in-out",
step === 1 || step === 2 ? "md:w-1/2" : "md:w-full"
step === 2 ? "md:w-1/2" : "md:w-full"
)}
>
<div
@ -1600,11 +1619,10 @@ function OnboardingWizardInner({
/>
)}
{/* 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. */}
{/* The hero, above the heading: one PillGuy held in the same tree
slot across steps 35, so React reuses the DOM node and moving
between steps never replays the entrance. It is dormant while
the agent is being specified and wakes on Review. */}
{step >= 3 && step <= 5 && (
// reducedMotion="user" defers to the OS setting, so the hero
// arrives in place for anyone who asked for less movement. The
@ -1620,19 +1638,14 @@ function OnboardingWizardInner({
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] : ""}
{/* Dormant until the agent is actually hired. Review is
the first step where one exists, so that is where it
wakes the arc's payoff, not a flourish along it. */}
<PillGuy
state={step === 5 ? "alive" : "dormant"}
className="size-(--sz-72px)"
/>
<AgentPreview agentName={agentName} agentRole="" />
</motion.div>
<OnboardingHeading
@ -1642,19 +1655,16 @@ function OnboardingWizardInner({
? "Create your first agent"
: step === 4
? "Connect a model"
: "Review"
: "Let's get started..."
}
// 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 ? (
<>
What model would you like your first agent to use? You can
choose different models when creating additional agents.
</>
<>Paperclip works with your existing subscription or API keys.</>
) : (
<>Your first agent is online and ready to work.</>
<>{agentName.trim() || "Your first agent"} is ready to work!</>
)
}
/>
@ -1749,21 +1759,18 @@ function OnboardingWizardInner({
</div>
)}
{/* Step 1: Name your company (both paths) */}
{/* Step 1: name the organization (both paths). One question, one
design: this mirrors the funnel's naming screen same
question, same sub, same left-aligned heading in a centered
column so a customer creating their second organization
in-app is asked exactly what their first one asked them. */}
{step === 1 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Name your organization</h3>
<p className="text-xs text-muted-foreground">
What should we call your team or company?
</p>
</div>
</div>
<div className="mt-3 group">
<div className="mx-auto w-full max-w-md space-y-6">
<OnboardingHeading
title="What is the name of your organization?"
lede="This will be the name of your Paperclip organization — choose something your team will recognize."
/>
<div className="group">
<label
className={cn(
"text-xs mb-1 block transition-colors",
@ -1776,7 +1783,7 @@ function OnboardingWizardInner({
</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="Acme Corp"
placeholder="e.g. Northwind Labs"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
onKeyDown={(e) => {
@ -1989,47 +1996,22 @@ function OnboardingWizardInner({
</div>
)}
{/* Step 3: role, then an optional name the prototype's field
pair, in its order and its widths. */}
{/* Step 3: the name, and only the name. The role picker went with
the question it was asking a customer naming their first
agent is describing what it does, and the placeholder carries
the range of answers that fit. Hiring uses the neutral
`general` role; a specific one can be set later, where there
is context to choose it in. */}
{step === 3 && (
<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>
<Label htmlFor="onboarding-agent-name">Name</Label>
<Input
id="onboarding-agent-name"
placeholder="Name"
placeholder="e.g. Chief of staff, Designer, Ron, Clippy..."
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
autoFocus
/>
</div>
</div>
@ -2038,11 +2020,10 @@ function OnboardingWizardInner({
{/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
{step === 4 && (
<div className="space-y-5">
{/* Adapter type radio cards */}
{/* The two cards are self-describing; an "Adapter type"
eyebrow above them named the mechanism rather than the
choice. */}
<div>
<label className="text-xs text-muted-foreground mb-2 block">
Adapter type
</label>
<div className="grid grid-cols-2 gap-2">
{recommendedAdapters.map((opt) => (
<button
@ -2066,11 +2047,9 @@ function OnboardingWizardInner({
setModel("");
}}
>
{opt.recommended && (
<Badge variant="ghost" className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-(length:--text-nano) font-semibold px-1.5 leading-none">
Recommended
</Badge>
)}
{/* No "Recommended" badge: it sat on both options,
so it recommended nothing and only added the one
saturated colour on the screen. */}
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-(length:--text-nano)">
@ -2090,7 +2069,7 @@ function OnboardingWizardInner({
showMoreAdapters ? "rotate-0" : "-rotate-90"
)}
/>
More Agent Adapter Types
Advanced settings
</button>
{showMoreAdapters && (
@ -2144,107 +2123,11 @@ function OnboardingWizardInner({
</div>
{/* Conditional adapter fields */}
{isLocalAdapter && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
</label>
<Popover
open={modelOpen}
onOpenChange={(next) => {
setModelOpen(next);
if (!next) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span
className={cn(
!model && "text-muted-foreground"
)}
>
{selectedModel
? selectedModel.label
: model ||
(adapterType === "opencode_local"
? "Select model (required)"
: "Default")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-1"
align="start"
>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{adapterType !== "opencode_local" && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!model && "bg-accent"
)}
onClick={() => {
setModel("");
setModelOpen(false);
}}
>
Default
</button>
)}
<div className="max-h-(--sz-240px) overflow-y-auto">
{groupedModels.map((group) => (
<div
key={group.provider}
className="mb-1 last:mb-0"
>
{adapterType === "opencode_local" && (
<div className="px-2 py-1 text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
</div>
)}
{group.entries.map((m) => (
<button
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === model && "bg-accent"
)}
onClick={() => {
setModel(m.id);
setModelOpen(false);
}}
>
<span
className="block w-full text-left truncate"
title={m.id}
>
{adapterType === "opencode_local"
? extractModelName(m.id)
: m.label}
</span>
</button>
))}
</div>
))}
</div>
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">
No models discovered.
</p>
)}
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* No model picker. Every adapter this step offers resolves
its own default (see buildAdapterConfig), so the picker
asked the customer to choose a model before they had any
way to judge one and the agent's model is changeable
later, where its work gives the choice meaning. */}
{/* The environment check runs without being shown: Connect
probes the adapter before hiring (see handleGiveHeartbeat)
@ -2393,37 +2276,10 @@ function OnboardingWizardInner({
)}
{/* Step 5: Review — lead is online (shared capsule above) */}
{step === 5 && (
<div className="space-y-5 py-1">
{/* Review checklist — everything that's now set up */}
<div className="space-y-1.5">
{/* No "Mission" row: onboarding stopped asking for one, so a
checklist item for it could only ever render unchecked
a permanent red mark for a question nobody was asked. */}
{[
{ label: "Organization name", done: Boolean(companyName.trim()) },
{ label: "Agent created", done: Boolean(createdAgentId) },
{ label: "Model connected", done: Boolean(createdAgentId) },
].map(({ label, done }) => (
<div key={label} className="flex items-center gap-2 text-sm">
<span
className={cn(
"flex h-4 w-4 items-center justify-center rounded-full shrink-0",
done
? "bg-green-500/15 text-green-600 dark:text-green-400"
: "bg-muted text-muted-foreground"
)}
>
<Check className="h-2.5 w-2.5" />
</span>
<span className={done ? "text-foreground" : "text-muted-foreground"}>
{label}
</span>
</div>
))}
</div>
</div>
)}
{/* Step 5: nothing. The heading names the agent and says it is
ready, and the pill above has just woken to show it a
checklist restating those in three rows only asked the
customer to audit work they watched happen. */}
{/* Error */}
{visibleError && (
@ -2448,7 +2304,7 @@ function OnboardingWizardInner({
loading={step === 3 ? false : loading}
primaryDisabled={
step === 3
? !agentRole
? !agentName.trim()
: step === 4
? loading || adapterEnvLoading || missionUnresolvedForHire
: loading || launchStateIncomplete
@ -2490,7 +2346,7 @@ function OnboardingWizardInner({
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : null}
Next
Continue
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
)}
@ -2563,7 +2419,7 @@ function OnboardingWizardInner({
<div
className={cn(
"hidden md:block overflow-hidden bg-muted text-muted-foreground transition-(--tp-width-opacity) duration-500 ease-in-out",
step === 1 || step === 2 ? "w-1/2 opacity-100" : "w-0 opacity-0"
step === 2 ? "w-1/2 opacity-100" : "w-0 opacity-0"
)}
>
<AsciiArtAnimation />

View File

@ -0,0 +1,117 @@
import { AnimatePresence, motion } from "motion/react";
import { cn } from "../../lib/utils";
/**
* The agent, drawn as itself.
*
* Two states from one silhouette: `dormant` is grey and closed-eyed, waiting to
* be configured; `alive` is the gradient-lit version with open eyes and a tuft,
* shown once the agent is hired and ready. The arc's job is to get from one to
* the other, so the transition between them is the arc's payoff not
* decoration on top of it.
*
* Both are the brand assets verbatim (`pill-1-dormant.svg`, `pill-1-alive.svg`)
* rather than a redrawn approximation, so what ships is what was designed. They
* carry their own fills the gradients are the point so they do not follow
* the theme, which is correct here: the agent looks like itself on either
* ground.
*/
const DORMANT_GRADIENT_ID = "pillguy-dormant-body";
const ALIVE_GRADIENT_ID = "pillguy-alive-body";
/** The shared silhouette. Identical in both states, which is what lets them cross-fade cleanly. */
const BODY_PATH =
"M54.7022 14.3438C29.7981 14.3438 9.60938 34.5085 9.60938 59.3831V87.5272C9.60938 90.385 11.9261 92.7018 14.784 92.7018H94.6204C97.4782 92.7018 99.795 90.385 99.795 87.5272V59.3831C99.795 34.5085 79.6063 14.3438 54.7022 14.3438Z";
/** The tuft, alive only — the one shape that has no dormant counterpart. */
const TUFT_PATH =
"M22.5464 10.6842C15.1541 21.0252 20.3287 39.5225 0 45.762C17.2549 61.9384 64.3127 49.1324 74.6619 21.781C79.4668 33.6086 90.5552 41.0009 96.469 42.8447C112.362 5.51809 69.8569 -5.24463 62.8342 3.25648C48.7889 -3.39656 29.7044 0.670936 22.5464 10.6842Z";
function DormantPill() {
return (
<svg viewBox="0 0 100 93" fill="none" aria-hidden className="size-full">
<path d={BODY_PATH} fill={`url(#${DORMANT_GRADIENT_ID})`} />
{/* Closed eyes: the same rounded rects as the open pair, flattened. */}
<rect x="75.9199" y="66.3047" width="9" height="4" rx="2" fill="#060606" />
<rect x="28.9199" y="66.3047" width="9" height="4" rx="2" fill="#060606" />
<defs>
<linearGradient
id={DORMANT_GRADIENT_ID}
x1="54.7022"
y1="14.3437"
x2="54.7022"
y2="107.486"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#626262" />
<stop offset="1" stopColor="#101010" />
</linearGradient>
</defs>
</svg>
);
}
function AlivePill() {
return (
<svg viewBox="0 0 100 93" fill="none" aria-hidden className="size-full">
<path d={BODY_PATH} fill={`url(#${ALIVE_GRADIENT_ID})`} />
<rect x="76.1406" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="#060606" />
<rect x="28.8301" y="63.1328" width="8.87072" height="10.3492" rx="4.43536" fill="#060606" />
<path d={TUFT_PATH} fill="#2D200D" />
<defs>
<linearGradient
id={ALIVE_GRADIENT_ID}
x1="54.7022"
y1="14.3437"
x2="54.7022"
y2="107.486"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3028AA" />
<stop offset="1" stopColor="#FF0000" />
</linearGradient>
</defs>
</svg>
);
}
/**
* Cross-faded rather than path-morphed. The two states share a silhouette but
* differ in fill, eye shape, and a tuft the dormant state does not have at all
* there is no honest path interpolation between them, and a faked one would
* warp the eyes through shapes the design never draws. Fading one over the
* other in place, with a small settle on the arriving state, reads as the
* agent waking rather than as two pictures swapping.
*
* `MotionConfig reducedMotion="user"` upstream neutralises the movement for
* anyone who asks; the state still changes, it simply arrives without travel.
*/
export function PillGuy({
state,
className,
}: {
state: "dormant" | "alive";
className?: string;
}) {
return (
<div className={cn("relative", className)}>
<AnimatePresence initial={false} mode="sync">
<motion.div
key={state}
className="absolute inset-0"
initial={{ opacity: 0, scale: state === "alive" ? 0.92 : 1 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{
opacity: { duration: 0.45, ease: [0.22, 1, 0.36, 1] },
scale: { duration: 0.55, ease: [0.22, 1, 0.36, 1] },
}}
>
{state === "alive" ? <AlivePill /> : <DormantPill />}
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@ -61,7 +61,7 @@ export function Stepper({
onJumpToStep?: (target: number) => void;
}) {
return (
<div className="mb-7 flex flex-col gap-3.5">
<div className="mb-7 flex flex-col items-start 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);
@ -74,7 +74,11 @@ export function Stepper({
disabled={!jumpable}
onClick={() => jumpable && onJumpToStep?.(segment)}
className={cn(
"h-(--sz-3px) flex-1 rounded-full transition-colors",
// Dots, not bars: three of them, left-aligned, keeping the bar
// strip's gap so the rhythm is unchanged. A full-width bar implied
// a continuous quantity — how much of the arc is done — which three
// discrete steps do not have.
"size-(--sz-3px) shrink-0 rounded-full transition-colors",
segment <= step ? "bg-foreground" : "bg-border",
jumpable ? "cursor-pointer" : "cursor-default",
)}

View File

@ -5,6 +5,17 @@ import { AGENT_ROLE_LABELS, type AgentRole } from "@paperclipai/shared";
* title rather than a role label because it reads as a person on the very
* first screen where the agent appears.
*/
/**
* The role every onboarding hire is filed under.
*
* The arc asks for a name, not a role: someone naming their first agent is
* describing what it should do, and the placeholder carries the range of
* answers that fit. `general` is the honest filing for that it claims
* nothing the customer did not say and the role can be set later, in the
* app, where the agent's work gives the choice meaning.
*/
export const DEFAULT_AGENT_ROLE = "general" as const;
export const DEFAULT_AGENT_NAME = "Chief of staff";
/**

View File

@ -272,6 +272,43 @@ describe("resolveRouteOnboardingOptions — the agent step", () => {
expect(resolved!.initialStep).not.toBe(ONBOARDING_MISSION_STEP);
});
it("never resolves into the create wizard on a managed stack", () => {
// POST /companies is a 403 floor on Cloud-managed stacks — a create wizard
// there is a dead end wearing a form. A managed stack holds exactly one
// company, so the useful reading of a bare or unmatched onboarding path is
// that company's agent arc.
const one = [{ id: "c1", issuePrefix: "PC1" }];
expect(
resolveRouteOnboardingOptions({
pathname: "/onboarding",
companies: one,
cloudManaged: true,
}),
).toEqual({ initialStep: ONBOARDING_AGENT_STEP, companyId: "c1" });
expect(
resolveRouteOnboardingOptions({
pathname: "/NOPE/onboarding",
companyPrefix: "NOPE",
companies: one,
cloudManaged: true,
}),
).toEqual({ initialStep: ONBOARDING_AGENT_STEP, companyId: "c1" });
});
it("opens nothing on a managed stack whose companies are not exactly one", () => {
// Zero companies means the list is still loading or errored — offering
// creation would 403; opening an arc would name nobody. Do neither.
for (const companies of [[], [{ id: "c1", issuePrefix: "PC1" }, { id: "c2", issuePrefix: "PC2" }]]) {
expect(
resolveRouteOnboardingOptions({
pathname: "/onboarding",
companies,
cloudManaged: true,
}),
).toBeNull();
}
});
it("keeps sending an unmatched prefix to company creation", () => {
expect(
resolveRouteOnboardingOptions({

View File

@ -79,12 +79,26 @@ export function resolveRouteOnboardingOptions(params: {
pathname: string;
companyPrefix?: string;
companies: OnboardingRouteCompany[];
/**
* Whether this instance is a Cloud-managed stack. Company creation lives on
* Cloud there POST /companies is a 403 floor so a route that cannot name
* an existing company must never open the create wizard: it would be a dead
* end wearing a form. A managed stack holds exactly one company, so the
* useful reading of a bare `/onboarding` is that company's agent arc.
*/
cloudManaged?: boolean;
}): { initialStep: 1 | ExistingCompanyOnboardingStep; companyId?: string } | null {
const { pathname, companyPrefix, companies } = params;
const { pathname, companyPrefix, companies, cloudManaged } = params;
if (!isOnboardingPath(pathname)) return null;
const managedFallback = (): { initialStep: ExistingCompanyOnboardingStep; companyId?: string } | null =>
companies.length === 1
? { initialStep: onboardingStepForCompany(), companyId: companies[0]!.id }
: null;
if (!companyPrefix) {
if (cloudManaged) return managedFallback();
return { initialStep: 1 };
}
@ -95,6 +109,7 @@ export function resolveRouteOnboardingOptions(params: {
) ?? null;
if (!matchedCompany) {
if (cloudManaged) return managedFallback();
return { initialStep: 1 };
}