diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts
index 81db129601..b69f9a6a16 100644
--- a/tests/e2e/conference-room-typing-intro.spec.ts
+++ b/tests/e2e/conference-room-typing-intro.spec.ts
@@ -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.
diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts
index c1bd87b419..cf3da3e3a6 100644
--- a/tests/e2e/nux-phase4-screenshots.spec.ts
+++ b/tests/e2e/nux-phase4-screenshots.spec.ts
@@ -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 });
diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts
index 87acc8ca6b..d7819493fd 100644
--- a/tests/e2e/onboarding.spec.ts
+++ b/tests/e2e/onboarding.spec.ts
@@ -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,
});
diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts
index 2bcefc9139..1c8978c2e8 100644
--- a/tests/e2e/planning-mode-visual-verification.spec.ts
+++ b/tests/e2e/planning-mode-visual-verification.spec.ts
@@ -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).
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index bf877bfa34..4e9d9fcee9 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -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() {
{title}
{description}
-
+ {/* 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 ? (
+
+ {t("app.cloudCreateUnavailable", {
+ defaultValue:
+ "Organizations are created in Paperclip Cloud. This instance can't reach it right now — try again from your Cloud portfolio.",
+ })}
+
+ ) : (
+
+ )}
@@ -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 (
@@ -569,9 +594,26 @@ function NoCompaniesStartPage() {
{t("app.noCompanies.description", { defaultValue: "Get started by creating a company." })}
-
+ {/* 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 ? (
+
+ {t("app.cloudCreateUnavailable", {
+ defaultValue:
+ "Organizations are created in Paperclip Cloud. This instance can't reach it right now — try again from your Cloud portfolio.",
+ })}
+
+ ) : (
+
+ )}
diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx
index f344f1171b..66304ba1a6 100644
--- a/ui/src/components/OnboardingWizard.step.test.tsx
+++ b/ui/src/components/OnboardingWizard.step.test.tsx
@@ -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 () => {
diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx
index 67b1a72ffe..d20e21cbfd 100644
--- a/ui/src/components/OnboardingWizard.test.tsx
+++ b/ui/src/components/OnboardingWizard.test.tsx
@@ -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(
+
+
+ ,
+ );
+ });
+ 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());
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index 1f06aec913..55a6880b69 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -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((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(
+ // `||`, 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((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({
)}
- {/* The hero, above the heading, as the prototype has it: one
- AgentCapsule held in the same tree slot across steps 3–5, 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 3–5, 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"
>
-
-
+
- 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({
)}
- {/* 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 && (
-
-
-
-
-
-
-
Name your organization
-
- What should we call your team or company?
-
-
-
-
+
+
+
)}
- {/* 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 && (
-
- {/* 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. */}
-
-
-
-
+
setAgentName(e.target.value)}
+ autoFocus
/>
@@ -2038,11 +2020,10 @@ function OnboardingWizardInner({
{/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
{step === 4 && (
- {/* Adapter type radio cards */}
+ {/* The two cards are self-describing; an "Adapter type"
+ eyebrow above them named the mechanism rather than the
+ choice. */}
- )}
+ {/* 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 && (
-
- {/* Review checklist — everything that's now set up */}
-
- {/* 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 }) => (
-
-
-
-
-
- {label}
-
-
- ))}
-
-
- )}
+ {/* 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 ? (
) : null}
- Next
+ Continue
)}
@@ -2563,7 +2419,7 @@ function OnboardingWizardInner({
diff --git a/ui/src/components/onboarding/PillGuy.tsx b/ui/src/components/onboarding/PillGuy.tsx
new file mode 100644
index 0000000000..588cfac87f
--- /dev/null
+++ b/ui/src/components/onboarding/PillGuy.tsx
@@ -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 (
+
+ );
+}
+
+function AlivePill() {
+ return (
+
+ );
+}
+
+/**
+ * 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 (
+
{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",
)}
diff --git a/ui/src/lib/onboarding-agent-role.ts b/ui/src/lib/onboarding-agent-role.ts
index 7dfaea7a2d..635e734963 100644
--- a/ui/src/lib/onboarding-agent-role.ts
+++ b/ui/src/lib/onboarding-agent-role.ts
@@ -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";
/**
diff --git a/ui/src/lib/onboarding-route.test.ts b/ui/src/lib/onboarding-route.test.ts
index 985a53f47e..e8af089fdf 100644
--- a/ui/src/lib/onboarding-route.test.ts
+++ b/ui/src/lib/onboarding-route.test.ts
@@ -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({
diff --git a/ui/src/lib/onboarding-route.ts b/ui/src/lib/onboarding-route.ts
index fe3f5f65e9..cd49b764d5 100644
--- a/ui/src/lib/onboarding-route.ts
+++ b/ui/src/lib/onboarding-route.ts
@@ -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 };
}