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. */}
-
{recommendedAdapters.map((opt) => ( {showMoreAdapters && ( @@ -2144,107 +2123,11 @@ function OnboardingWizardInner({
{/* Conditional adapter fields */} - {isLocalAdapter && ( -
-
- - { - setModelOpen(next); - if (!next) setModelSearch(""); - }} - > - - - - - setModelSearch(e.target.value)} - autoFocus - /> - {adapterType !== "opencode_local" && ( - - )} -
- {groupedModels.map((group) => ( -
- {adapterType === "opencode_local" && ( -
- {group.provider} ({group.entries.length}) -
- )} - {group.entries.map((m) => ( - - ))} -
- ))} -
- {filteredModels.length === 0 && ( -

- No models discovered. -

- )} -
-
-
-
- )} + {/* 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 ( + + + {/* Closed eyes: the same rounded rects as the open pair, flattened. */} + + + + + + + + + + ); +} + +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 ( +
+ + + {state === "alive" ? : } + + +
+ ); +} diff --git a/ui/src/components/onboarding/Stepper.tsx b/ui/src/components/onboarding/Stepper.tsx index 1db18c62d5..335b9e960f 100644 --- a/ui/src/components/onboarding/Stepper.tsx +++ b/ui/src/components/onboarding/Stepper.tsx @@ -61,7 +61,7 @@ export function Stepper({ onJumpToStep?: (target: number) => void; }) { 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 }; }