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:
parent
24d639abad
commit
eb86fcd498
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 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"
|
||||
>
|
||||
<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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue