This reverts commit11e56654f8. #10786 ported the onboarding flow from a standalone prototype and repointed `/onboarding` at the new `CloudOnboardingFlow`, deleting the existing `OnboardingWizard.tsx` in the process. The ported flow is not ready to be the shipping onboarding experience: it landed as a single large port rather than an incremental migration, it pulled `motion`, `three` and `@types/three` onto the UI dependency list for prototype visuals, and it deleted the wizard that four in-flight pull requests (#9900, #9501, #8982 and one more) were building on — those went CONFLICTING the moment the file disappeared. Rather than keep the half-migrated state on master while that is sorted out, back the port out whole and re-land it incrementally. This restores `OnboardingWizard.tsx` and the previous versions of the four e2e specs, drops the `onboarding-preview.html` Vite entry, the DesignGuide onboarding section and the `data-viz-misc` storybook story, and removes the three prototype dependencies from `ui/package.json`. This is an exact mechanical inverse of the squash commit — 41 files, +2089/-3647, no hand edits. Reverting this commit restores all 41 files byte for byte, so the port is recoverable in full when it is ready. `pnpm-lock.yaml` is deliberately not touched. #10786 never updated it; bot commit4683f26c9(#11036) added the `motion`/`three` entries afterwards, so the lockfile is now ahead of the manifest. CI owns lockfile updates (`.github/workflows/pr.yml`) and the policy job regenerates it from the changed manifest. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6b7e0814a0
commit
384e5f6178
|
|
@ -7,13 +7,6 @@
|
|||
"runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"],
|
||||
"port": 3108,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "ui-preview",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@paperclipai/ui", "exec", "vite"],
|
||||
"port": 5188,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,24 +188,6 @@ Use in property rows, comment headers, assignee displays, and anywhere a user/ag
|
|||
**File:** `CompanySwitcher.tsx`
|
||||
**Usage:** Company selector dropdown in sidebar header.
|
||||
|
||||
### AgentCapsule
|
||||
|
||||
**File:** `AgentCapsule.tsx`
|
||||
**Props:** `state: "slot" | "configured" | "online"`, `gradient?: 1–10`, `size?: "sm" | "md" | "lg" | {width,height}`, `glow?: "green" | "blue"`
|
||||
**Usage:** The brand "capsule is the agent" pill; evolves in place across onboarding steps. Fill uses `--agent-Na/Nb` gradient tokens; honors `prefers-reduced-motion`.
|
||||
|
||||
### Onboarding primitives (OnboardingCard, OnboardingHeading, Stepper, Chip, ChoiceCard, ConnectorRow)
|
||||
|
||||
**File:** `onboarding/OnboardingPrimitives.tsx`
|
||||
**Usage:** Presentational pieces for the full-screen onboarding flow (`onboarding/OnboardingFlow.tsx`): the 560px card frame (`--sz-560px`), display heading + lede (text-4xl), 3-segment stepper, selectable mission chips, selectable choice cards, and connector rows. Bespoke dimensions route through verbatim `--sz-*` tokens; fields inside the flow use the shared Input/Textarea/Select/Label primitives.
|
||||
|
||||
```tsx
|
||||
<OnboardingCard>
|
||||
<Stepper step={2} />
|
||||
<OnboardingHeading title="Create your first agent" lede="..." />
|
||||
</OnboardingCard>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout Components
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow";
|
||||
|
||||
/**
|
||||
* E2E: post-onboarding launch.
|
||||
* E2E: post-wizard onboarding launch.
|
||||
*
|
||||
* Completing the onboarding flow creates the first assigned task and lands the
|
||||
* user on the company dashboard. The chat intro still has unit coverage in
|
||||
* BoardChat tests; the onboarding handoff no longer routes there.
|
||||
* Completing the onboarding wizard now creates the first assigned task and
|
||||
* lands the user on the company dashboard. The chat intro still has unit
|
||||
* coverage in BoardChat tests; the wizard handoff no longer routes there.
|
||||
*/
|
||||
|
||||
const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`;
|
||||
const MISSION = "Verify the dashboard launch survives the onboarding handoff.";
|
||||
const MISSION = "Verify the dashboard launch survives the wizard handoff.";
|
||||
const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan";
|
||||
|
||||
test.describe("Dashboard launch after onboarding", () => {
|
||||
test.describe("Dashboard launch after onboarding wizard", () => {
|
||||
test("creates the first task and opens the dashboard", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
// Intercept env-test → instant pass (avoid running a real CLI check).
|
||||
await page.route("**/test-environment", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "pass", checks: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Intercept hire → perform a REAL hire server-side with an inert http
|
||||
// adapter so no real agent process spawns. (The cloud flow hires with
|
||||
// requireEnvProbe: false, so there is no adapter-environment probe to stub.)
|
||||
// adapter so no real agent process spawns.
|
||||
await page.route("**/agent-hires", async (route) => {
|
||||
const req = route.request();
|
||||
const body = JSON.parse(req.postData() || "{}");
|
||||
|
|
@ -47,13 +54,38 @@ test.describe("Dashboard launch after onboarding", () => {
|
|||
|
||||
await page.goto("/onboarding");
|
||||
|
||||
// Welcome → company (name + mission) → agent (role picker) → first task.
|
||||
// "Get started" on the task step creates the task and opens the dashboard.
|
||||
await completeCloudOnboarding(page, {
|
||||
companyName: COMPANY_NAME,
|
||||
mission: MISSION,
|
||||
choice: "hiring",
|
||||
// Launcher card path (existing companies) — enter the wizard if the
|
||||
// route shows a launcher instead of opening the wizard directly.
|
||||
const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
|
||||
if (await startBtn.count()) await startBtn.first().click();
|
||||
|
||||
// Step 0: front door (skipped when the wizard opens on the create path).
|
||||
const frontDoor = page.getByText("Build a new company");
|
||||
if (await frontDoor.count()) await frontDoor.first().click();
|
||||
|
||||
// Step 1: company name.
|
||||
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
|
||||
// Step 2: mission (direct path default).
|
||||
await page
|
||||
.getByPlaceholder("What is your team trying to achieve?")
|
||||
.fill(MISSION);
|
||||
await page.getByRole("button", { name: /Confirm mission/ }).click();
|
||||
|
||||
// Step 3: lead name (prefilled) → Next.
|
||||
await page.waitForSelector('input[placeholder="Chief of staff"]', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
|
||||
// Step 4: adapter (claude_local default); heartbeat is intercepted.
|
||||
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
|
||||
|
||||
// Step 5: review → Get started creates the first task and opens dashboard.
|
||||
const getStarted = page.getByRole("button", { name: /Get started/ });
|
||||
await getStarted.waitFor({ timeout: 20_000 });
|
||||
await getStarted.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
|
||||
|
||||
|
|
@ -66,8 +98,8 @@ test.describe("Dashboard launch after onboarding", () => {
|
|||
const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
|
||||
expect(issuesRes.ok()).toBe(true);
|
||||
const issues = await issuesRes.json();
|
||||
const firstTask = issues.find((candidate: { title: string }) => candidate.title === HIRING_TASK_TITLE);
|
||||
const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE);
|
||||
expect(firstTask).toBeTruthy();
|
||||
await expect(page.getByText(HIRING_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DEFAULT_ROLE, startCloudOnboarding } from "./onboarding-flow";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -10,43 +9,40 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||
* NUX Phase 4 — visual QA screenshot capture.
|
||||
*
|
||||
* Boots a throwaway local_trusted instance (see playwright.config.ts webServer)
|
||||
* and captures screenshots of every integrated onboarding surface:
|
||||
* - Welcome screen (path picker)
|
||||
* - Company step (name + mission)
|
||||
* - Create-your-first-agent step (role picker + capsule)
|
||||
* - First-task step
|
||||
* - "Add an agent to an existing company" entry (/:prefix/onboarding)
|
||||
* and captures screenshots of every surface integrated by NUX Phases 1–3:
|
||||
* - "Build a new company" step 1 (company name) + step 2 (mission)
|
||||
* - Team-lead hire step (capsule wizard, PAP-125)
|
||||
* - Onboarding front door (path picker)
|
||||
* - "Add agents to your org" growth intake
|
||||
* - Conference Room (BoardChat) shell + composer + activity feed
|
||||
* - Artifacts page
|
||||
*
|
||||
* These are structural/rendering checks — LLM-dependent streaming (CEO chat
|
||||
* responses, hiring-plan generation) is verified separately on an LLM-backed
|
||||
* instance. Screenshots land in ./test-results for upload as evidence.
|
||||
* instance. Screenshots land in ./nux-phase4-shots for upload as evidence.
|
||||
*/
|
||||
|
||||
// Write under the gitignored test-results dir so re-runs leave no untracked
|
||||
// noise; screenshots are uploaded to the issue as QA evidence, not committed.
|
||||
const SHOT_DIR = path.join(__dirname, "test-results", "nux-phase4-shots");
|
||||
|
||||
const SHOTS = [
|
||||
"01-welcome.png",
|
||||
"02-company.png",
|
||||
"03-agent.png",
|
||||
"04-first-task.png",
|
||||
"05-add-agent.png",
|
||||
"06-board-chat.png",
|
||||
"07-artifacts.png",
|
||||
];
|
||||
|
||||
function shot(name: string) {
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
return path.join(SHOT_DIR, name);
|
||||
}
|
||||
|
||||
async function openWizard(page: import("@playwright/test").Page) {
|
||||
await page.goto("/onboarding");
|
||||
const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ });
|
||||
if (await startBtn.count()) {
|
||||
await startBtn.first().click();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("NUX Phase 4 visual QA", () => {
|
||||
test("captures every integrated surface", async ({ page }) => {
|
||||
// Conference Room is flag-gated default-OFF: turn the experimental flag on
|
||||
// for this throwaway instance before driving that surface (Section C).
|
||||
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
|
||||
// experimental flag on for this throwaway instance before driving them.
|
||||
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
|
||||
data: { enableConferenceRoomChat: true },
|
||||
});
|
||||
|
|
@ -61,43 +57,36 @@ test.describe("NUX Phase 4 visual QA", () => {
|
|||
const baseUrl =
|
||||
"http://127.0.0.1:" + (process.env.PAPERCLIP_E2E_PORT ?? "3199");
|
||||
|
||||
// ── Section A: the cloud flow, step by step ───────────────────────────
|
||||
await page.goto("/onboarding");
|
||||
|
||||
// ── Section A: create-company path (name → mission → hire) ────────────
|
||||
await openWizard(page);
|
||||
// Front door shows when the wizard doesn't open directly on the create
|
||||
// path (e.g. another spec already created a company on this instance).
|
||||
const createCard = page.getByRole("button", { name: /Build a new company/ });
|
||||
if (await createCard.count()) {
|
||||
await createCard.first().click();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Welcome to Paperclip!" }),
|
||||
page.getByRole("heading", { name: "Name your company" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: shot("01-welcome.png") });
|
||||
await page.getByPlaceholder("Acme Corp").fill("QA Robotics");
|
||||
await page.screenshot({ path: shot("02-create-name.png") });
|
||||
|
||||
await startCloudOnboarding(page);
|
||||
|
||||
// Capture the company step populated but not yet submitted, then submit.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "What is the name of your company or team?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.locator("#onboarding-company-name").fill("QA Robotics");
|
||||
await page
|
||||
.locator("#onboarding-mission")
|
||||
.fill("Build affordable home robots that handle household chores.");
|
||||
await page.screenshot({ path: shot("02-company.png") });
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
|
||||
// Agent step: pick a role so the capsule + preview render, then capture
|
||||
// before hiring.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Create your first agent" }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await page.locator("#onboarding-agent-role").click();
|
||||
await page.getByRole("option", { name: DEFAULT_ROLE, exact: true }).click();
|
||||
await page.screenshot({ path: shot("03-agent.png") });
|
||||
await page.getByRole("button", { name: /^Create/ }).click();
|
||||
page.getByRole("heading", { name: "Define your mission" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page
|
||||
.getByPlaceholder("What is your team trying to achieve?")
|
||||
.fill("Build affordable home robots that handle household chores.");
|
||||
await page.screenshot({ path: shot("03-create-mission.png") });
|
||||
|
||||
// First-task step: select a choice so the card's selected state is visible.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Assign your agent a first task" }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /Create a hiring plan/ }).click();
|
||||
await page.screenshot({ path: shot("04-first-task.png") });
|
||||
// Step 2 advances via "Confirm mission" (creates the company + goal);
|
||||
// step 3 is the team-lead naming step of the capsule wizard.
|
||||
await page.getByRole("button", { name: /Confirm mission/ }).click();
|
||||
await page.waitForSelector('input[placeholder="Chief of staff"]', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.screenshot({ path: shot("04-hire-team-lead.png") });
|
||||
|
||||
// The company just created anchors the route-scoped sections below.
|
||||
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
|
||||
|
|
@ -106,23 +95,39 @@ test.describe("NUX Phase 4 visual QA", () => {
|
|||
const qaCompany = (Array.isArray(companies) ? companies : []).find(
|
||||
(c: { name: string }) => c.name === "QA Robotics",
|
||||
);
|
||||
expect(qaCompany, "onboarding should have created QA Robotics").toBeTruthy();
|
||||
expect(qaCompany, "wizard should have created QA Robotics").toBeTruthy();
|
||||
const prefix: string = qaCompany.issuePrefix;
|
||||
|
||||
// ── Section B: "add an agent to an existing company" entry ────────────
|
||||
// OnboardingWizardVariant renders outside <Routes> (App.tsx), so it never
|
||||
// sees the :companyPrefix param and the company-scoped route still opens on
|
||||
// the welcome screen. The real existing-company entry is the launcher card
|
||||
// behind it: dismiss the overlay, then "Add Agent" opens onboarding scoped
|
||||
// to this company, which skips company creation and starts at the agent step.
|
||||
// ── Section B: front door + growth intake ─────────────────────────────
|
||||
await page.evaluate(() => window.localStorage.clear());
|
||||
await page.goto(`/${prefix}/onboarding`);
|
||||
await page.getByRole("button", { name: "Close onboarding" }).click();
|
||||
await page.getByRole("button", { name: "Add Agent" }).click();
|
||||
await openWizard(page);
|
||||
// Reach the full-screen front door (step 0): either it shows directly or
|
||||
// "← Back to start" returns to it from the create step.
|
||||
if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) {
|
||||
await page.getByRole("button", { name: /Back to start/ }).click();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Create your first agent" }),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await page.screenshot({ path: shot("05-add-agent.png") });
|
||||
page.getByRole("heading", { name: "Welcome to Paperclip" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Build a new company" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Add agents to your org" }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: shot("01-front-door.png") });
|
||||
|
||||
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 company" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByPlaceholder("Acme Corp").fill("QA Robotics Grow");
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Tell us about your team/ }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page.screenshot({ path: shot("05-growth-intake.png") });
|
||||
|
||||
// ── Section C: Conference Room (BoardChat) ────────────────────────────
|
||||
// Visit the company dashboard first so CompanyContext selects the company
|
||||
|
|
@ -147,7 +152,15 @@ test.describe("NUX Phase 4 visual QA", () => {
|
|||
await page.waitForTimeout(1_000);
|
||||
await page.screenshot({ path: shot("07-artifacts.png") });
|
||||
|
||||
for (const f of SHOTS) {
|
||||
for (const f of [
|
||||
"01-front-door.png",
|
||||
"02-create-name.png",
|
||||
"03-create-mission.png",
|
||||
"04-hire-team-lead.png",
|
||||
"05-growth-intake.png",
|
||||
"06-board-chat.png",
|
||||
"07-artifacts.png",
|
||||
]) {
|
||||
const p = shot(f);
|
||||
expect(fs.existsSync(p), `missing ${f}`).toBe(true);
|
||||
expect(fs.statSync(p).size, `empty ${f}`).toBeGreaterThan(1_000);
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Shared driver for the cloud onboarding flow (ui/src/components/onboarding/).
|
||||
*
|
||||
* The flow replaced the retired OnboardingWizard, which had a front door, a
|
||||
* separate company-name step, a separate mission step, an adapter step and a
|
||||
* review step. The cloud flow is four screens:
|
||||
*
|
||||
* start — "Welcome to Paperclip!" (unnumbered)
|
||||
* company — name + mission on one card; "Next" creates the company + goal
|
||||
* agent — role picker (+ optional name); "Create" hires the lead agent
|
||||
* task — first-task choice; "Get started" launches it and opens the dashboard
|
||||
*
|
||||
* Four specs drove the old wizard's selectors, so the step drivers live here
|
||||
* rather than being copy-pasted: when the flow changes again, this is the one
|
||||
* file to update.
|
||||
*/
|
||||
|
||||
/** Default role picked in the agent step. */
|
||||
export const DEFAULT_ROLE = "Chief of Staff";
|
||||
|
||||
/**
|
||||
* Name the agent step auto-fills when DEFAULT_ROLE is picked. Selecting a role
|
||||
* populates the (optional) name field with the role's acronym — see
|
||||
* ROLE_ACRONYMS in ui/src/components/onboarding/onboarding-data.ts.
|
||||
*/
|
||||
export const DEFAULT_ROLE_ACRONYM = "COS";
|
||||
|
||||
/** Title of the task created by the "hiring" first-task choice. */
|
||||
export const HIRING_TASK_TITLE = "Hire your first engineer and create a hiring plan";
|
||||
|
||||
export type FirstTaskChoice = "hiring" | "strategy" | "custom";
|
||||
|
||||
/**
|
||||
* Matches each first-task ChoiceCard. The cards are buttons whose accessible
|
||||
* name is title + description, so these match on the title fragment only.
|
||||
*/
|
||||
const CHOICE_CARD: Record<FirstTaskChoice, RegExp> = {
|
||||
hiring: /Create a hiring plan/,
|
||||
strategy: /Write a team strategy doc/,
|
||||
// Trailing ellipsis in the UI copy is deliberately not matched.
|
||||
custom: /Write your own task/,
|
||||
};
|
||||
|
||||
/** Step 0 — the welcome screen. Advances to the first numbered step. */
|
||||
export async function startCloudOnboarding(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("heading", { name: "Welcome to Paperclip!" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /Set up Paperclip for your company or team/ })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 — company name + mission on a single card. "Next" persists the company
|
||||
* and its company-level goal, then advances to the agent step.
|
||||
*/
|
||||
export async function completeCompanyStep(
|
||||
page: Page,
|
||||
{ companyName, mission }: { companyName: string; mission: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "What is the name of your company or team?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.locator("#onboarding-company-name").fill(companyName);
|
||||
await page.locator("#onboarding-mission").fill(mission);
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — role picker (a Radix select) plus an optional name. "Create" hires
|
||||
* the lead agent. Pass `name` to override the acronym the role auto-fills.
|
||||
*/
|
||||
export async function completeAgentStep(
|
||||
page: Page,
|
||||
{ role = DEFAULT_ROLE, name }: { role?: string; name?: string } = {},
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("heading", { name: "Create your first agent" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.locator("#onboarding-agent-role").click();
|
||||
await page.getByRole("option", { name: role, exact: true }).click();
|
||||
if (name !== undefined) {
|
||||
await page.locator("#onboarding-agent-name").fill(name);
|
||||
}
|
||||
await page.getByRole("button", { name: /^Create/ }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 — pick the first task and launch it. "Get started" creates the task
|
||||
* and navigates to the company dashboard.
|
||||
*/
|
||||
export async function completeTaskStep(
|
||||
page: Page,
|
||||
{ choice = "hiring", customTask }: { choice?: FirstTaskChoice; customTask?: string } = {},
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Assign your agent a first task" }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: CHOICE_CARD[choice] }).click();
|
||||
if (choice === "custom") {
|
||||
await page.getByPlaceholder("Describe the first task").fill(customTask ?? "");
|
||||
}
|
||||
await page.getByRole("button", { name: /Get started/ }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the whole cloud flow from an already-loaded /onboarding route through
|
||||
* to the dashboard navigation. Callers that need to assert or screenshot
|
||||
* between steps should call the individual step drivers instead.
|
||||
*/
|
||||
export async function completeCloudOnboarding(
|
||||
page: Page,
|
||||
{
|
||||
companyName,
|
||||
mission,
|
||||
role = DEFAULT_ROLE,
|
||||
choice = "hiring",
|
||||
customTask,
|
||||
}: {
|
||||
companyName: string;
|
||||
mission: string;
|
||||
role?: string;
|
||||
choice?: FirstTaskChoice;
|
||||
customTask?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await startCloudOnboarding(page);
|
||||
await completeCompanyStep(page, { companyName, mission });
|
||||
await completeAgentStep(page, { role });
|
||||
await completeTaskStep(page, { choice, customTask });
|
||||
}
|
||||
|
|
@ -1,46 +1,79 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { completeCompanyStep, startCloudOnboarding } from "./onboarding-flow";
|
||||
|
||||
/**
|
||||
* E2E: cloud onboarding flow.
|
||||
* E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard).
|
||||
*
|
||||
* The flow opens on a welcome screen and then runs three numbered steps:
|
||||
* Step 0 — Welcome (unnumbered path picker)
|
||||
* Step 1 — Company name + mission
|
||||
* Step 2 — Create your first agent (role picker)
|
||||
* Step 3 — Assign your agent a first task
|
||||
* The wizard now opens on a front door (path picker) and the "Create a new
|
||||
* company" path runs:
|
||||
* Step 0 — Front door (Create a new company / Level up existing)
|
||||
* Step 1a — Name your company
|
||||
* Step 1b — Define your mission (direct or guided)
|
||||
* Step 2 — Hire your team lead (adapter picker)
|
||||
* Step 3+ — Launch celebration → CEO chat → hiring plan → orientation
|
||||
*
|
||||
* This test covers the deterministic, LLM-free core: it drives the welcome
|
||||
* screen through the company step (which creates the company and a
|
||||
* company-level goal) and verifies the flow advances to the agent step.
|
||||
* This test covers the deterministic, LLM-free core: it drives the front door
|
||||
* through company naming + mission definition (which creates the company and a
|
||||
* company-level goal) and verifies the wizard advances to the team-lead step.
|
||||
*
|
||||
* The tail (hiring the agent, launching the first task) is covered by
|
||||
* conference-room-typing-intro.spec.ts; surface-level rendering of every step
|
||||
* is snapshotted by nux-phase4-screenshots.spec.ts.
|
||||
* The tail (CEO chat at step 4, hiring-plan generation at step 5, final
|
||||
* landing) depends on a live LLM and is verified separately during manual /
|
||||
* LLM-backed QA — see PAP-50. Surface-level rendering of every step is
|
||||
* snapshotted by nux-phase4-screenshots.spec.ts.
|
||||
*/
|
||||
|
||||
const COMPANY_NAME = `E2E-Test-${Date.now()}`;
|
||||
const MISSION = "Build affordable home robots that handle household chores.";
|
||||
|
||||
test.describe("Onboarding flow", () => {
|
||||
test("company step: name + mission creates company and goal", async ({
|
||||
test.describe("Onboarding wizard", () => {
|
||||
test("create-company path: name + mission creates company and goal", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
|
||||
// experimental flag on for this throwaway instance before driving them.
|
||||
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
|
||||
data: { enableConferenceRoomChat: true },
|
||||
});
|
||||
expect(flagRes.ok()).toBe(true);
|
||||
|
||||
await page.goto("/onboarding");
|
||||
|
||||
await startCloudOnboarding(page);
|
||||
await completeCompanyStep(page, {
|
||||
companyName: COMPANY_NAME,
|
||||
mission: MISSION,
|
||||
// The wizard may open on a launcher card or directly on the capsule
|
||||
// wizard; the front door (step 0) requires a click into the create path.
|
||||
const startBtn = page.getByRole("button", {
|
||||
name: /Start Onboarding|New Company|Add Agent/,
|
||||
});
|
||||
if (await startBtn.count()) {
|
||||
await startBtn.first().click();
|
||||
}
|
||||
const createCard = page.getByRole("button", { name: /Build a new company/ });
|
||||
if (await createCard.count()) {
|
||||
await createCard.first().click();
|
||||
}
|
||||
|
||||
// Reaching the agent step means the company + goal writes succeeded.
|
||||
// Step 1 — Name your company.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Create your first agent" }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
page.getByRole("heading", { name: "Name your company" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
|
||||
// Step 2 — Define your mission (direct entry is the default path).
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Define your mission" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page
|
||||
.getByPlaceholder("What is your team trying to achieve?")
|
||||
.fill(MISSION);
|
||||
|
||||
// "Confirm mission" creates the company + a company-level goal, then
|
||||
// advances to the team-lead naming step of the capsule wizard.
|
||||
await page.getByRole("button", { name: /Confirm mission/ }).click();
|
||||
await page.waitForSelector('input[placeholder="Chief of staff"]', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Verify the company + company-level goal were persisted.
|
||||
const baseUrl = page.url().split("/").slice(0, 3).join("/");
|
||||
|
|
@ -62,7 +95,7 @@ test.describe("Onboarding flow", () => {
|
|||
);
|
||||
expect(companyGoal, "a company-level goal should be created").toBeTruthy();
|
||||
|
||||
// The flow must not crash the app (Rules-of-Hooks regression).
|
||||
// The expanded wizard must not crash the app (Rules-of-Hooks regression).
|
||||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const AGENT_NAME = "Chief of staff";
|
||||
const TASK_TITLE = "Hire your first engineer and create a hiring plan";
|
||||
|
||||
test("captures planning mode UI for desktop and mobile", async ({ page }) => {
|
||||
const timestamp = Date.now();
|
||||
const companyName = `PAP-3413-${timestamp}`;
|
||||
// Resolve against this file, not the cwd, so screenshots land in the
|
||||
// gitignored tests/e2e/test-results/ rather than an untracked dir at the
|
||||
// repo root that a contributor could commit by accident.
|
||||
const screenshotDir = path.join(__dirname, "test-results", "planning-mode");
|
||||
const screenshotDir = "test-results/planning-mode";
|
||||
|
||||
await page.route("**/test-environment", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "pass", checks: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Intercept hire → perform a REAL hire server-side with an inert http adapter
|
||||
// so no real agent process spawns. (The cloud flow hires with
|
||||
// requireEnvProbe: false, so there is no adapter-environment probe to stub.)
|
||||
await page.route("**/agent-hires", async (route) => {
|
||||
const req = route.request();
|
||||
const body = JSON.parse(req.postData() || "{}");
|
||||
|
|
@ -42,15 +41,31 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
|
|||
});
|
||||
|
||||
await page.goto("/onboarding");
|
||||
const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ });
|
||||
if (await startBtn.count()) await startBtn.first().click();
|
||||
|
||||
// This spec only needs a company with a seeded first task to screenshot the
|
||||
// planning-mode UI against; drive the whole onboarding flow to get one.
|
||||
await completeCloudOnboarding(page, {
|
||||
companyName,
|
||||
mission: "Capture planning mode visual evidence for the graduated task UI.",
|
||||
choice: "hiring",
|
||||
});
|
||||
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 company" })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.locator('input[placeholder="Acme Corp"]').fill(companyName);
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Define your mission" })).toBeVisible({ timeout: 30_000 });
|
||||
await page
|
||||
.getByPlaceholder("What is your team trying to achieve?")
|
||||
.fill("Capture planning mode visual evidence for the graduated task UI.");
|
||||
await page.getByRole("button", { name: /Confirm mission/ }).click();
|
||||
|
||||
await page.waitForSelector('input[placeholder="Chief of staff"]', { timeout: 30_000 });
|
||||
await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME);
|
||||
|
||||
await page.getByRole("button", { name: /^Next/ }).click();
|
||||
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /Get started/ }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
|
||||
|
||||
const baseOrigin = new URL(page.url()).origin;
|
||||
|
|
@ -64,7 +79,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
|
|||
const issues = await issueRes.json();
|
||||
const planningSeedIssue = issues.find(
|
||||
(candidate: { id: string; identifier?: string; title: string }) =>
|
||||
candidate.title === HIRING_TASK_TITLE,
|
||||
candidate.title === TASK_TITLE,
|
||||
);
|
||||
expect(planningSeedIssue).toBeTruthy();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Onboarding Preview</title>
|
||||
<style>
|
||||
/* Unlayered override beats Tailwind's @layer base: index.css pins
|
||||
html,body to viewport height with overflow hidden (correct for the app
|
||||
shell). This standalone page needs to scroll. */
|
||||
html, body { height: auto; min-height: 100%; overflow-y: auto; }
|
||||
</style>
|
||||
<script>
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/onboarding-preview-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -59,7 +59,6 @@
|
|||
"lexical": "0.48.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"motion": "^12.42.2",
|
||||
"radix-ui": "^1.6.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
|
|
@ -68,8 +67,7 @@
|
|||
"react-resizable-panels": "^4.12.2",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "^0.185.1"
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "10.5.4",
|
||||
|
|
@ -79,7 +77,6 @@
|
|||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.185.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"storybook": "10.5.5",
|
||||
"tailwindcss": "^4.3.2",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import * as React from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -9,14 +8,14 @@ import { cn } from "@/lib/utils";
|
|||
* A single agent is drawn as a tall pill (proportion 1:≥2, radius 9999px)
|
||||
* that moves through three states as the agent comes to life:
|
||||
*
|
||||
* - `slot` — static dashed outline. An empty agent slot.
|
||||
* - `slot` — dashed outline, gently pulsing. An empty agent slot.
|
||||
* - `configured` — solid stroke. Agent named / model picked, not yet live.
|
||||
* - `online` — brand agent-gradient fill radiates outward from the center,
|
||||
* - `online` — brand agent-gradient liquid rises to fill the capsule,
|
||||
* which then breathes with an online-pulse ring (green by
|
||||
* default, or blue via `glow="blue"`).
|
||||
*
|
||||
* The three states are drawn as stacked layers (a dashed outline, a solid
|
||||
* stroke, and the radial fill) that cross-fade by opacity. Because
|
||||
* stroke, and the rising liquid) that cross-fade by opacity. Because
|
||||
* `border-style` is not animatable, the dashed→solid morph is realized as the
|
||||
* dashed layer fading out while the solid layer fades in — so the SAME capsule
|
||||
* can evolve in place across a flow (PAP-125, Option 4 wizard).
|
||||
|
|
@ -25,7 +24,7 @@ import { cn } from "@/lib/utils";
|
|||
* `--agent-Na` (top) → `--agent-Nb` (bottom); pick which one with `gradient`
|
||||
* (1–10). Size is a preset (`sm` | `md` | `lg`) or an explicit pixel pair so
|
||||
* the component is reusable app-wide. `prefers-reduced-motion` is honored in
|
||||
* CSS — the radial fill, layer cross-fade and both pulses are skipped and the
|
||||
* CSS — the liquid rise, layer cross-fade and both pulses are skipped and the
|
||||
* final state is rendered statically.
|
||||
*/
|
||||
|
||||
|
|
@ -61,21 +60,10 @@ export interface AgentCapsuleProps
|
|||
size?: AgentCapsuleSizePreset | { width: number; height: number };
|
||||
/** Online-pulse colour (only applies in the `online` state). Defaults to `green`. */
|
||||
glow?: AgentCapsuleGlow;
|
||||
/**
|
||||
* Slot→configured morph as a draw-on: the solid outline is traced around the
|
||||
* perimeter over the still-visible dashed outline (which fades once the draw
|
||||
* completes), instead of the default border cross-fade. Honors reduced
|
||||
* motion by rendering the final state instantly.
|
||||
*/
|
||||
strokeDraw?: boolean;
|
||||
/** Accessible label; defaults to a description of the state. */
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
/** Duration of the strokeDraw perimeter trace; the dashed layer fades after it. */
|
||||
const STROKE_DRAW_SECONDS = 0.9;
|
||||
const STROKE_DRAW_EASE = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
/** Normalize a (possibly out-of-range) gradient index to 1…AGENT_GRADIENT_COUNT. */
|
||||
function normalizeGradient(gradient: number): number {
|
||||
const n = Math.trunc(gradient);
|
||||
|
|
@ -87,7 +75,6 @@ export function AgentCapsule({
|
|||
gradient = 1,
|
||||
size = "md",
|
||||
glow = "green",
|
||||
strokeDraw = false,
|
||||
className,
|
||||
style,
|
||||
"aria-label": ariaLabel,
|
||||
|
|
@ -96,8 +83,6 @@ export function AgentCapsule({
|
|||
const dims = typeof size === "string" ? SIZE_PRESETS[size] : size;
|
||||
const idx = normalizeGradient(gradient);
|
||||
const fill = `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`;
|
||||
const reducedMotion = useReducedMotion();
|
||||
const drawn = state === "configured" || state === "online";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -114,68 +99,29 @@ export function AgentCapsule({
|
|||
style={{ width: dims.width, height: dims.height, ...style }}
|
||||
{...rest}
|
||||
>
|
||||
{/* Dashed outline — an empty agent slot. Visible (static) only in the
|
||||
slot state; cross-fades out as the capsule is configured. In
|
||||
strokeDraw mode it instead stays put while the solid outline is
|
||||
traced over it, then fades once the draw completes. */}
|
||||
{/* Dashed outline — an empty agent slot. Visible (and pulsing) only in
|
||||
the slot state; cross-fades out as the capsule is configured. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"agent-cap-dash agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-dashed border-muted-foreground/60",
|
||||
state === "slot" ? "agent-cap-slot opacity-100" : "opacity-0",
|
||||
)}
|
||||
style={
|
||||
strokeDraw && state === "configured" && !reducedMotion
|
||||
? { transitionDelay: `${STROKE_DRAW_SECONDS}s` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* Solid stroke — agent configured, not yet live. Default: cross-fades
|
||||
in on top of the dashed layer. strokeDraw: an SVG outline traced
|
||||
around the perimeter (pathLength 0→1) over the dashed layer. */}
|
||||
{strokeDraw ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox={`0 0 ${dims.width} ${dims.height}`}
|
||||
fill="none"
|
||||
>
|
||||
<motion.rect
|
||||
x={1}
|
||||
y={1}
|
||||
width={dims.width - 2}
|
||||
height={dims.height - 2}
|
||||
rx={(dims.width - 2) / 2}
|
||||
strokeWidth={2}
|
||||
className="stroke-foreground/70"
|
||||
initial={false}
|
||||
animate={{ pathLength: drawn ? 1 : 0 }}
|
||||
transition={
|
||||
reducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: STROKE_DRAW_SECONDS, ease: STROKE_DRAW_EASE }
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"agent-cap-stroke agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-solid border-foreground/70",
|
||||
state === "configured" && "opacity-100",
|
||||
// Online: carry the solid outline over, then fade it as the fill completes.
|
||||
state === "online" && "agent-cap-stroke-carryover",
|
||||
state === "slot" && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Brand-gradient fill — radiates outward from the center when online. */}
|
||||
{/* Solid stroke — agent configured, not yet live. Cross-fades in on top
|
||||
of the dashed layer, then out as the liquid rises. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"agent-cap-stroke agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-solid border-foreground/70",
|
||||
state === "configured" ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{/* Brand-gradient liquid — rises to fill the capsule when online. */}
|
||||
{state === "online" ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="agent-cap-liquid absolute inset-0 block"
|
||||
className="agent-cap-liquid absolute inset-x-0 bottom-0 block h-full"
|
||||
style={{ background: fill }}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,51 +3,25 @@
|
|||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The shell reads dialog + company state and renders CloudOnboardingFlow. Mock those
|
||||
// so the test exercises the shell's gating + option→prop mapping in isolation.
|
||||
const dialogState = vi.hoisted(() => ({ value: null as unknown }));
|
||||
vi.mock("@/context/DialogContext", () => ({
|
||||
useDialog: () => dialogState.value,
|
||||
}));
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "c1", issuePrefix: "ACME", name: "Acme" }],
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: "/dashboard" }),
|
||||
useParams: () => ({}),
|
||||
}));
|
||||
vi.mock("./onboarding/CloudOnboardingFlow", () => ({
|
||||
CloudOnboardingFlow: (props: { initialStep?: string; existingCompany?: { id: string } }) => (
|
||||
<div
|
||||
data-testid="onboarding-flow"
|
||||
data-initial-step={props.initialStep}
|
||||
data-company={props.existingCompany?.id ?? ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { OnboardingWizardVariant } from "./OnboardingWizardVariant";
|
||||
|
||||
function baseDialog(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
onboardingOpen: false,
|
||||
onboardingOptions: {},
|
||||
closeOnboarding: vi.fn(),
|
||||
onboardingRouteDismissed: true,
|
||||
setOnboardingRouteDismissed: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OnboardingWizardVariant", () => {
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./OnboardingWizard", () => ({
|
||||
OnboardingWizard: () => <div data-testid="wizard-capsule" />,
|
||||
}));
|
||||
|
||||
describe("OnboardingWizardVariant (PAP-138)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
function render() {
|
||||
function renderVariant() {
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root!.render(<OnboardingWizardVariant />);
|
||||
|
|
@ -68,29 +42,11 @@ describe("OnboardingWizardVariant", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when onboarding is closed", () => {
|
||||
dialogState.value = baseDialog();
|
||||
render();
|
||||
expect(container.querySelector('[data-testid="onboarding-flow"]')).toBeNull();
|
||||
});
|
||||
it("renders the capsule wizard without reading the chat flag", () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({});
|
||||
renderVariant();
|
||||
|
||||
it("renders the flow at the start step for a fresh open", () => {
|
||||
dialogState.value = baseDialog({ onboardingOpen: true, onboardingOptions: {} });
|
||||
render();
|
||||
const el = container.querySelector('[data-testid="onboarding-flow"]');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.getAttribute("data-initial-step")).toBe("start");
|
||||
expect(el?.getAttribute("data-company")).toBe("");
|
||||
});
|
||||
|
||||
it("starts at the agent step with the company preset when adding to an existing company", () => {
|
||||
dialogState.value = baseDialog({
|
||||
onboardingOpen: true,
|
||||
onboardingOptions: { initialStep: 2, companyId: "c1" },
|
||||
});
|
||||
render();
|
||||
const el = container.querySelector('[data-testid="onboarding-flow"]');
|
||||
expect(el?.getAttribute("data-initial-step")).toBe("agent");
|
||||
expect(el?.getAttribute("data-company")).toBe("c1");
|
||||
expect(container.querySelector('[data-testid="wizard-capsule"]')).not.toBeNull();
|
||||
expect(mockInstanceSettingsApi.getExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,72 +1,10 @@
|
|||
import { useEffect } from "react";
|
||||
import { useLocation, useParams } from "@/lib/router";
|
||||
import { useDialog } from "@/context/DialogContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { resolveRouteOnboardingOptions } from "@/lib/onboarding-route";
|
||||
import { CloudOnboardingFlow } from "./onboarding/CloudOnboardingFlow";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
|
||||
/**
|
||||
* App-integration shell for onboarding. Owns the open/close + route logic and
|
||||
* renders the CloudOnboardingFlow (the real app targets the cloud flow; the
|
||||
* local flow is harness-only for now). Onboarding opens either explicitly via
|
||||
* the dialog context (openOnboarding) or automatically on the /onboarding route
|
||||
* (and its /:companyPrefix/onboarding "add an agent" variant).
|
||||
* Default onboarding wizard. Conference-room chat is now the only surface left
|
||||
* behind `enableConferenceRoomChat`; onboarding stays available without that
|
||||
* experimental flag.
|
||||
*/
|
||||
export function OnboardingWizardVariant() {
|
||||
const {
|
||||
onboardingOpen,
|
||||
onboardingOptions,
|
||||
closeOnboarding,
|
||||
onboardingRouteDismissed,
|
||||
setOnboardingRouteDismissed,
|
||||
} = useDialog();
|
||||
const { companies, loading: companiesLoading } = useCompany();
|
||||
const location = useLocation();
|
||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
|
||||
// Reset the route-dismissed flag when navigating to a different path, so the
|
||||
// route can re-open onboarding after the user has dismissed it elsewhere.
|
||||
useEffect(() => {
|
||||
setOnboardingRouteDismissed(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Support opening from the /onboarding route in addition to the dialog. Wait
|
||||
// for companies to load before resolving a company-scoped route so we don't
|
||||
// momentarily treat an existing-company entry as a fresh one.
|
||||
const routeOptions =
|
||||
companyPrefix && companiesLoading
|
||||
? null
|
||||
: resolveRouteOnboardingOptions({
|
||||
pathname: location.pathname,
|
||||
companyPrefix,
|
||||
companies,
|
||||
});
|
||||
|
||||
const open = onboardingOpen || (routeOptions !== null && !onboardingRouteDismissed);
|
||||
if (!open) return null;
|
||||
|
||||
const options = onboardingOpen ? onboardingOptions : routeOptions ?? {};
|
||||
const existingCompany = options.companyId
|
||||
? companies.find((company) => company.id === options.companyId)
|
||||
: undefined;
|
||||
|
||||
function handleClose() {
|
||||
closeOnboarding();
|
||||
// On the /onboarding route the shell is also kept open by the route itself,
|
||||
// so closing must mark the route dismissed — otherwise `open` stays true and
|
||||
// the flow re-renders instead of handing back to the launcher card (PAP-52).
|
||||
setOnboardingRouteDismissed(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<CloudOnboardingFlow
|
||||
initialStep={existingCompany ? "agent" : "start"}
|
||||
existingCompany={
|
||||
existingCompany
|
||||
? { id: existingCompany.id, prefix: existingCompany.issuePrefix }
|
||||
: undefined
|
||||
}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
return <OnboardingWizard />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import { motion } from "motion/react";
|
||||
import { PREVIEW_REVEAL_DURATION, STEP_EASE } from "./onboarding-motion";
|
||||
|
||||
/**
|
||||
* Name/role preview under the capsule (agent + task steps). Collapsed (zero
|
||||
* height) until an identity exists, so the step opens compact; on selection the
|
||||
* block grows to full height — the viewport-centered card re-centers each
|
||||
* frame, so the capsule slides smoothly up into place — while the labels fade
|
||||
* in without moving. Slide and fade share one duration; the fade starts after
|
||||
* 25% of it. Both lines keep fixed heights once visible so typing a name
|
||||
* afterwards never shifts the layout again.
|
||||
*/
|
||||
export function AgentPreview({
|
||||
agentName,
|
||||
agentRole,
|
||||
}: {
|
||||
agentName: string;
|
||||
agentRole: string;
|
||||
}) {
|
||||
const previewVisible = Boolean(agentName || agentRole);
|
||||
return (
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
initial={false}
|
||||
animate={{ height: previewVisible ? "auto" : 0 }}
|
||||
transition={{ duration: PREVIEW_REVEAL_DURATION, ease: STEP_EASE }}
|
||||
>
|
||||
<motion.div
|
||||
className="mt-1 flex flex-col items-center gap-1"
|
||||
initial={false}
|
||||
animate={{ opacity: previewVisible ? 1 : 0 }}
|
||||
transition={{
|
||||
duration: PREVIEW_REVEAL_DURATION,
|
||||
ease: STEP_EASE,
|
||||
delay: previewVisible ? PREVIEW_REVEAL_DURATION * 0.25 : 0,
|
||||
}}
|
||||
>
|
||||
<span className="flex h-6 items-center text-base font-semibold tracking-tight text-foreground">
|
||||
{agentName || " "}
|
||||
</span>
|
||||
<span className="flex h-4 items-center text-xs text-muted-foreground">
|
||||
{agentRole || " "}
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
// Cloud onboarding flow — the thin container that owns state + backend wiring
|
||||
// and composes the shared step views inside the shared OnboardingScaffold.
|
||||
// Advances Start → Company → Agent → Task, then launches the first task and
|
||||
// opens the dashboard. The local flow (LocalOnboardingFlow) reuses the same
|
||||
// shared steps + shell and inserts its extra steps.
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
|
||||
import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
|
||||
import { OnboardingScaffold } from "./OnboardingScaffold";
|
||||
import { StartStep } from "./steps/StartStep";
|
||||
import { CompanyStep } from "./steps/CompanyStep";
|
||||
import { AgentStep } from "./steps/AgentStep";
|
||||
import { TaskStep } from "./steps/TaskStep";
|
||||
|
||||
type Step = "start" | "company" | "agent" | "task";
|
||||
|
||||
const DEFAULT_ADAPTER = "claude_local";
|
||||
|
||||
// Numbered steps drive the Stepper position ("Step N of M").
|
||||
const NUMBERED: Step[] = ["company", "agent", "task"];
|
||||
function stepper(s: Step) {
|
||||
return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
|
||||
}
|
||||
|
||||
export interface CloudOnboardingFlowProps {
|
||||
/** Called when the user dismisses onboarding. */
|
||||
onClose?: () => void;
|
||||
/** Starting step. Defaults to "start". Used by the standalone preview harness. */
|
||||
initialStep?: Step;
|
||||
/**
|
||||
* Preview-only: skip all backend calls (company/agent/task creation) so the
|
||||
* flow can be clicked end-to-end without a backend. Used by the standalone
|
||||
* preview harness — never enabled in the real app.
|
||||
*/
|
||||
previewMock?: boolean;
|
||||
/**
|
||||
* When set, onboarding runs against an already-created company (the "add an
|
||||
* agent to an existing company" entry): company creation is skipped and the
|
||||
* flow starts at the agent step.
|
||||
*/
|
||||
existingCompany?: { id: string; prefix: string | null };
|
||||
}
|
||||
|
||||
export function CloudOnboardingFlow({
|
||||
onClose,
|
||||
initialStep = "start",
|
||||
previewMock = false,
|
||||
existingCompany,
|
||||
}: CloudOnboardingFlowProps) {
|
||||
const navigate = useNavigate();
|
||||
const flow = useOnboardingFlow(
|
||||
existingCompany
|
||||
? { createdCompanyId: existingCompany.id, createdCompanyPrefix: existingCompany.prefix }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<Step>(initialStep);
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [mission, setMission] = useState("");
|
||||
const [agentRole, setAgentRole] = useState("");
|
||||
const [agentName, setAgentName] = useState("");
|
||||
const [taskChoice, setTaskChoice] = useState<FirstTaskChoice | null>(null);
|
||||
const [customTask, setCustomTask] = useState("");
|
||||
|
||||
function handleRoleChange(value: string) {
|
||||
setAgentRole(value);
|
||||
setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
|
||||
}
|
||||
|
||||
async function handleCreateCompany() {
|
||||
if (previewMock) {
|
||||
setStep("agent");
|
||||
return;
|
||||
}
|
||||
const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
|
||||
if (result) setStep("agent");
|
||||
}
|
||||
|
||||
async function handleCreateAgent() {
|
||||
if (previewMock) {
|
||||
setStep("task");
|
||||
return;
|
||||
}
|
||||
const result = await flow.hireLeadAgent({
|
||||
agentName: agentName || agentRole,
|
||||
adapter: { adapterType: DEFAULT_ADAPTER, model: "", command: "", args: "", url: "" },
|
||||
instructions: {
|
||||
companyName,
|
||||
companyGoal: mission,
|
||||
growPath: false,
|
||||
growWorkflows: "",
|
||||
growPainPoints: "",
|
||||
growAutomate: "",
|
||||
q1: "",
|
||||
q2: "",
|
||||
q3: "",
|
||||
q4: "",
|
||||
},
|
||||
// The cloud agent step has no environment probe; keep hire simple.
|
||||
requireEnvProbe: false,
|
||||
});
|
||||
if (result) setStep("task");
|
||||
}
|
||||
|
||||
async function handleGetStarted() {
|
||||
if (!taskChoice) return;
|
||||
if (previewMock) {
|
||||
window.alert(
|
||||
"Preview complete — in the real app this launches the first task and opens your dashboard.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
|
||||
if (result) {
|
||||
onClose?.();
|
||||
navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingScaffold stepKey={step} onClose={onClose}>
|
||||
{step === "start" && <StartStep onSetup={() => setStep("company")} />}
|
||||
{step === "company" && (
|
||||
<CompanyStep
|
||||
{...stepper("company")}
|
||||
companyName={companyName}
|
||||
onCompanyNameChange={setCompanyName}
|
||||
mission={mission}
|
||||
onMissionChange={setMission}
|
||||
onBack={() => setStep("start")}
|
||||
onNext={handleCreateCompany}
|
||||
loading={flow.loading}
|
||||
/>
|
||||
)}
|
||||
{step === "agent" && (
|
||||
<AgentStep
|
||||
{...stepper("agent")}
|
||||
agentRole={agentRole}
|
||||
agentName={agentName}
|
||||
onRoleChange={handleRoleChange}
|
||||
onNameChange={setAgentName}
|
||||
onBack={existingCompany ? () => onClose?.() : () => setStep("company")}
|
||||
onNext={handleCreateAgent}
|
||||
loading={flow.loading}
|
||||
/>
|
||||
)}
|
||||
{step === "task" && (
|
||||
<TaskStep
|
||||
{...stepper("task")}
|
||||
agentName={agentName}
|
||||
agentRole={agentRole}
|
||||
taskChoice={taskChoice}
|
||||
onSelectChoice={setTaskChoice}
|
||||
customTask={customTask}
|
||||
onCustomTaskChange={setCustomTask}
|
||||
onBack={() => setStep("agent")}
|
||||
onGetStarted={handleGetStarted}
|
||||
loading={flow.loading}
|
||||
error={flow.error}
|
||||
/>
|
||||
)}
|
||||
</OnboardingScaffold>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Shared footer navigation for onboarding step cards: a ghost pill "Back"
|
||||
* button (visible on hover) and a primary pill CTA that shows a spinner +
|
||||
* loading label while its action runs.
|
||||
*/
|
||||
export function FooterNav({
|
||||
onBack,
|
||||
primaryLabel,
|
||||
primaryDisabled,
|
||||
loading,
|
||||
loadingLabel,
|
||||
onPrimary,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
primaryLabel: string;
|
||||
primaryDisabled?: boolean;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
onPrimary: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{/* has-[>svg]:pr-4 gives the "Back" text room from the pill's right edge
|
||||
(overrides size="sm"'s has-[>svg]:px-2.5 for the right side only). */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-full has-[>svg]:pr-4"
|
||||
onClick={onBack}
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="mr-1 size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-full px-6"
|
||||
onClick={onPrimary}
|
||||
disabled={primaryDisabled || loading}
|
||||
>
|
||||
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : null}
|
||||
{loading && loadingLabel ? loadingLabel : primaryLabel}
|
||||
{!loading ? <ArrowRight className="ml-1 size-3.5" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { Github, Star } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
|
||||
|
||||
const PAPERCLIP_REPO_URL = "https://github.com/paperclipai/paperclip";
|
||||
|
||||
/**
|
||||
* Local-flow only: a warm "star us on GitHub" interstitial shown after "Get
|
||||
* started", before landing in the app. "Star on GitHub" opens the repo in a new
|
||||
* tab and continues; "Not right now" just continues. Both call onContinue,
|
||||
* which runs the same completion as the cloud flow (launch task → dashboard).
|
||||
*/
|
||||
export function GithubStarInterstitial({ onContinue }: { onContinue: () => void }) {
|
||||
return (
|
||||
<OnboardingCard className="text-center">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<span className="grid size-16 place-items-center rounded-full bg-muted/50 text-foreground">
|
||||
<Star className="size-7" />
|
||||
</span>
|
||||
<OnboardingHeading
|
||||
title="Enjoying Paperclip so far?"
|
||||
lede="Paperclip is open source. A GitHub star helps other people find it and means a lot to a small team — it takes two seconds."
|
||||
center
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full rounded-full"
|
||||
onClick={() => {
|
||||
window.open(PAPERCLIP_REPO_URL, "_blank", "noopener,noreferrer");
|
||||
onContinue();
|
||||
}}
|
||||
>
|
||||
<Github className="mr-1.5 size-4" />
|
||||
Star on GitHub
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="rounded-full" onClick={onContinue}>
|
||||
Not right now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
// Local onboarding flow — the thin container for the local (self-hosted) app.
|
||||
// Reuses the shared step views + shell (OnboardingScaffold) and inserts the
|
||||
// local-only pieces: an optional email ask before the numbered steps, a
|
||||
// model/adapter step after naming the agent (which runs the env probe on hire),
|
||||
// and a "star us on GitHub" interstitial after "Get started".
|
||||
//
|
||||
// Order: Start → Email → Company(1) → Agent(2) → Adapter(3) → Task(4)
|
||||
// → [Get started] → GitHub-star interstitial → dashboard.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
|
||||
import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
|
||||
import { OnboardingScaffold } from "./OnboardingScaffold";
|
||||
import { AUTH_EXIT_DURATION, OnboardingAuthBackdrop } from "./OnboardingAuthBackdrop";
|
||||
import { GithubStarInterstitial } from "./GithubStarInterstitial";
|
||||
import { StartStep } from "./steps/StartStep";
|
||||
import { EmailStep } from "./steps/EmailStep";
|
||||
import { CompanyStep } from "./steps/CompanyStep";
|
||||
import { AgentStep } from "./steps/AgentStep";
|
||||
import { AdapterStep } from "./steps/AdapterStep";
|
||||
import { TaskStep } from "./steps/TaskStep";
|
||||
|
||||
type Step = "start" | "email" | "company" | "agent" | "adapter" | "task";
|
||||
|
||||
const DEFAULT_ADAPTER = "claude_local";
|
||||
|
||||
// Numbered steps drive the Stepper position ("Step N of M").
|
||||
const NUMBERED: Step[] = ["company", "agent", "adapter", "task"];
|
||||
function stepper(s: Step) {
|
||||
return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
|
||||
}
|
||||
|
||||
export interface LocalOnboardingFlowProps {
|
||||
onClose?: () => void;
|
||||
initialStep?: Step;
|
||||
previewMock?: boolean;
|
||||
}
|
||||
|
||||
export function LocalOnboardingFlow({
|
||||
onClose,
|
||||
initialStep = "start",
|
||||
previewMock = false,
|
||||
}: LocalOnboardingFlowProps) {
|
||||
const navigate = useNavigate();
|
||||
const flow = useOnboardingFlow();
|
||||
|
||||
const [step, setStep] = useState<Step>(initialStep);
|
||||
const [showGithubStar, setShowGithubStar] = useState(false);
|
||||
// The welcome screen is rendered by OnboardingAuthBackdrop — the same shell
|
||||
// the cloud flow's auth screens use — so leaving it plays the identical
|
||||
// hand-off: card lowers + fades while the paperclip fades out, and only once
|
||||
// that finishes does the step shell mount. "exiting" holds that gap.
|
||||
const [welcomePhase, setWelcomePhase] = useState<"welcome" | "exiting" | "done">(
|
||||
initialStep === "start" ? "welcome" : "done",
|
||||
);
|
||||
const welcomeTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(welcomeTimer.current), []);
|
||||
|
||||
function leaveWelcome() {
|
||||
setWelcomePhase("exiting");
|
||||
welcomeTimer.current = window.setTimeout(() => {
|
||||
setStep("email");
|
||||
setWelcomePhase("done");
|
||||
}, AUTH_EXIT_DURATION * 1000);
|
||||
}
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [mission, setMission] = useState("");
|
||||
const [agentRole, setAgentRole] = useState("");
|
||||
const [agentName, setAgentName] = useState("");
|
||||
const [adapterType, setAdapterType] = useState(DEFAULT_ADAPTER);
|
||||
const [taskChoice, setTaskChoice] = useState<FirstTaskChoice | null>(null);
|
||||
const [customTask, setCustomTask] = useState("");
|
||||
|
||||
const adapterInput = { adapterType, model: "", command: "", args: "", url: "" };
|
||||
|
||||
// Switching adapters invalidates the previous adapter's environment probe:
|
||||
// drop it so the retry re-probes and the step stops showing a verdict about
|
||||
// an adapter the user is no longer hiring on.
|
||||
function handleAdapterChange(value: string) {
|
||||
setAdapterType(value);
|
||||
flow.clearAdapterEnvResult();
|
||||
}
|
||||
|
||||
function handleRoleChange(value: string) {
|
||||
setAgentRole(value);
|
||||
setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
|
||||
}
|
||||
|
||||
async function handleCreateCompany() {
|
||||
if (previewMock) {
|
||||
setStep("agent");
|
||||
return;
|
||||
}
|
||||
const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
|
||||
if (result) setStep("agent");
|
||||
}
|
||||
|
||||
// Adapter step → hire the lead agent on the chosen local adapter, requiring
|
||||
// the environment probe to pass (the local flow's key difference from cloud).
|
||||
async function handleHireAgent() {
|
||||
if (previewMock) {
|
||||
setStep("task");
|
||||
return;
|
||||
}
|
||||
const result = await flow.hireLeadAgent({
|
||||
agentName: agentName || agentRole,
|
||||
adapter: adapterInput,
|
||||
instructions: {
|
||||
companyName,
|
||||
companyGoal: mission,
|
||||
growPath: false,
|
||||
growWorkflows: "",
|
||||
growPainPoints: "",
|
||||
growAutomate: "",
|
||||
q1: "",
|
||||
q2: "",
|
||||
q3: "",
|
||||
q4: "",
|
||||
},
|
||||
requireEnvProbe: true,
|
||||
});
|
||||
if (result) setStep("task");
|
||||
}
|
||||
|
||||
function handleGetStarted() {
|
||||
if (!taskChoice) return;
|
||||
setShowGithubStar(true);
|
||||
}
|
||||
|
||||
// Runs from the interstitial's CTAs — the same completion as the cloud flow.
|
||||
async function completeOnboarding() {
|
||||
if (!taskChoice) return;
|
||||
if (previewMock) {
|
||||
window.alert(
|
||||
"Preview complete — in the real app this launches the first task and opens your dashboard.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
|
||||
if (result) {
|
||||
onClose?.();
|
||||
navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
|
||||
}
|
||||
}
|
||||
|
||||
// Welcome screen: same shell + exit transition as the cloud auth screens.
|
||||
// The local flow skips sign-in, so this screen carries the orbiting paperclip.
|
||||
if (welcomePhase !== "done") {
|
||||
return (
|
||||
<OnboardingAuthBackdrop visible={welcomePhase === "welcome"} onClose={onClose}>
|
||||
<StartStep onSetup={leaveWelcome} />
|
||||
</OnboardingAuthBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingScaffold stepKey={showGithubStar ? "github-star" : step} onClose={onClose}>
|
||||
{showGithubStar ? (
|
||||
<GithubStarInterstitial onContinue={completeOnboarding} />
|
||||
) : (
|
||||
<>
|
||||
{step === "email" && (
|
||||
<EmailStep
|
||||
email={email}
|
||||
onEmailChange={setEmail}
|
||||
onContinue={() => setStep("company")}
|
||||
onSkip={() => setStep("company")}
|
||||
/>
|
||||
)}
|
||||
{step === "company" && (
|
||||
<CompanyStep
|
||||
{...stepper("company")}
|
||||
companyName={companyName}
|
||||
onCompanyNameChange={setCompanyName}
|
||||
mission={mission}
|
||||
onMissionChange={setMission}
|
||||
onBack={() => setStep("email")}
|
||||
onNext={handleCreateCompany}
|
||||
loading={flow.loading}
|
||||
/>
|
||||
)}
|
||||
{step === "agent" && (
|
||||
<AgentStep
|
||||
{...stepper("agent")}
|
||||
agentRole={agentRole}
|
||||
agentName={agentName}
|
||||
onRoleChange={handleRoleChange}
|
||||
onNameChange={setAgentName}
|
||||
onBack={() => setStep("company")}
|
||||
onNext={() => setStep("adapter")}
|
||||
primaryLabel="Next"
|
||||
/>
|
||||
)}
|
||||
{step === "adapter" && (
|
||||
<AdapterStep
|
||||
{...stepper("adapter")}
|
||||
adapterType={adapterType}
|
||||
onAdapterChange={handleAdapterChange}
|
||||
agentName={agentName}
|
||||
agentRole={agentRole}
|
||||
onBack={() => setStep("agent")}
|
||||
onNext={handleHireAgent}
|
||||
loading={flow.loading}
|
||||
/>
|
||||
)}
|
||||
{step === "task" && (
|
||||
<TaskStep
|
||||
{...stepper("task")}
|
||||
agentName={agentName}
|
||||
agentRole={agentRole}
|
||||
taskChoice={taskChoice}
|
||||
onSelectChoice={setTaskChoice}
|
||||
customTask={customTask}
|
||||
onCustomTaskChange={setCustomTask}
|
||||
onBack={() => setStep("adapter")}
|
||||
onGetStarted={handleGetStarted}
|
||||
loading={flow.loading}
|
||||
error={flow.error}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OnboardingScaffold>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { STEP_EASE } from "./onboarding-motion";
|
||||
|
||||
// three.js is large, so the orbiting-paperclip canvas is code-split: it only
|
||||
// downloads when the auth screens actually render.
|
||||
const PaperclipOrbit3D = lazy(() =>
|
||||
import("./PaperclipOrbit3D").then((m) => ({ default: m.PaperclipOrbit3D })),
|
||||
);
|
||||
|
||||
/** Duration of the hand-off out of the auth screens (modal + backdrop together). */
|
||||
export const AUTH_EXIT_DURATION = 0.35;
|
||||
|
||||
/**
|
||||
* The orbiting-paperclip layer plus its scrim, as a non-interactive fill layer.
|
||||
* Used behind the auth screens (cloud) and behind the welcome screen (local,
|
||||
* which skips auth entirely). The scrim keeps the page calm without muting the
|
||||
* gradient; panels above it are translucent so the motif reads through.
|
||||
*/
|
||||
export function PaperclipBackdropLayer({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("pointer-events-none absolute inset-0", className)}>
|
||||
<Suspense fallback={null}>
|
||||
<PaperclipOrbit3D className="size-full" />
|
||||
</Suspense>
|
||||
{/* Very slight darkening scrim — enough to settle the backdrop behind the
|
||||
panels without muting the gradient. Applies to every screen that shows
|
||||
the paperclip (cloud auth screens + the local welcome screen), since
|
||||
both render through this layer. */}
|
||||
<div className="absolute inset-0 bg-background/20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen shell for the onboarding auth screens (create account / verify
|
||||
* code), with the orbiting 3D paperclip behind the card.
|
||||
*
|
||||
* On completion (`visible` → false) the card settles down and fades while the
|
||||
* backdrop fades out at the same time — a quick, subtle hand-off into whatever
|
||||
* comes next. Render this around the auth screens and flip `visible` when the
|
||||
* user finishes signing up or logging in.
|
||||
*/
|
||||
export function OnboardingAuthBackdrop({
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
/** Optional dismiss affordance, matching OnboardingScaffold's close button. */
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<motion.div
|
||||
key="onboarding-auth"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-background px-5 py-20"
|
||||
initial={false}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: AUTH_EXIT_DURATION, ease: STEP_EASE }}
|
||||
>
|
||||
{/* Orbiting paperclip backdrop — sits behind the card, non-interactive.
|
||||
It fades with the container; a dimming overlay keeps the card
|
||||
legible over the bright gradient. */}
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 -z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: AUTH_EXIT_DURATION, ease: STEP_EASE }}
|
||||
>
|
||||
<PaperclipBackdropLayer />
|
||||
</motion.div>
|
||||
|
||||
{onClose ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute left-4 top-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
aria-label="Close onboarding"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* The card lowers slightly and fades on the way out. */}
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={false}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: AUTH_EXIT_DURATION, ease: STEP_EASE }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
// Presentational-only auth screens (create account + email OTP), ported from
|
||||
// the prototype onto the repo's design tokens. These are NOT wired to real
|
||||
// auth — the app authenticates via better-auth upstream of onboarding. They
|
||||
// exist so the full onboarding visual arc can be previewed. See the preview
|
||||
// harness (onboarding-preview-main.tsx).
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
|
||||
|
||||
function GoogleMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.5 12.2c0-.7-.1-1.4-.2-2H12v3.9h5.9a5 5 0 0 1-2.2 3.3v2.7h3.6c2.1-2 3.2-4.9 3.2-7.9z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.9 0 5.4-1 7.2-2.6l-3.6-2.7c-1 .7-2.3 1.1-3.6 1.1-2.8 0-5.1-1.9-6-4.4H2.3v2.8A11 11 0 0 0 12 23z"
|
||||
/>
|
||||
<path fill="#FBBC05" d="M6 14.4a6.6 6.6 0 0 1 0-4.2V7.4H2.3a11 11 0 0 0 0 9.8L6 14.4z" />
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.4c1.6 0 3 .5 4.1 1.6l3.1-3.1A11 11 0 0 0 2.3 7.4L6 10.2c.9-2.6 3.2-4.8 6-4.8z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GithubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2A10 10 0 0 0 8.8 21.5c.5.1.7-.2.7-.5v-1.8c-2.8.6-3.4-1.2-3.4-1.2-.5-1.2-1.1-1.5-1.1-1.5-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.3-4.5-1.1-4.5-4.9 0-1.1.4-2 1-2.7-.1-.3-.4-1.3.1-2.7 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 5 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.7.6.7 1 1.6 1 2.7 0 3.8-2.3 4.6-4.5 4.9.4.3.7.9.7 1.8v2.7c0 .3.2.6.7.5A10 10 0 0 0 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialButton({ mark, label }: { mark: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center justify-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-3 text-sm font-medium text-foreground transition-colors hover:border-muted-foreground"
|
||||
>
|
||||
<span className="size-(--sz-18px)">{mark}</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Create-account screen (visual only). */
|
||||
export function AccountScreen({ onContinue }: { onContinue?: () => void }) {
|
||||
return (
|
||||
<OnboardingCard translucent>
|
||||
<div className="space-y-6">
|
||||
<OnboardingHeading
|
||||
title="Create an account"
|
||||
lede="Free while Paperclip is in beta. No credit card required."
|
||||
center
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="onboarding-auth-email">Email</Label>
|
||||
<Input id="onboarding-auth-email" type="email" placeholder="you@company.com" />
|
||||
<span className="text-xs leading-snug text-muted-foreground">
|
||||
We'll send you a 6-digit code to confirm your email.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="onboarding-auth-password">Password</Label>
|
||||
<Input id="onboarding-auth-password" type="password" placeholder="••••••••" />
|
||||
<span className="text-xs leading-snug text-muted-foreground">At least 8 characters.</span>
|
||||
</div>
|
||||
<Button size="lg" className="w-full rounded-full" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3.5 text-xs text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
Or continue with
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<SocialButton mark={<GoogleMark className="size-(--sz-18px)" />} label="Google" />
|
||||
<SocialButton mark={<GithubMark className="size-(--sz-18px)" />} label="GitHub" />
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<span className="text-foreground underline-offset-2 hover:underline">Sign in</span>
|
||||
</p>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Email OTP confirmation screen (visual only). */
|
||||
export function OtpScreen({ email, onContinue }: { email?: string; onContinue?: () => void }) {
|
||||
const [vals, setVals] = useState<string[]>(["", "", "", "", "", ""]);
|
||||
const refs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
function setAt(i: number, v: string) {
|
||||
setVals((a) => a.map((x, k) => (k === i ? v : x)));
|
||||
}
|
||||
function onChange(i: number, raw: string) {
|
||||
const v = raw.replace(/\D/g, "").slice(0, 1);
|
||||
setAt(i, v);
|
||||
if (v && i < 5) refs.current[i + 1]?.focus();
|
||||
}
|
||||
function onKeyDown(i: number, e: React.KeyboardEvent) {
|
||||
if (e.key === "Backspace" && !vals[i] && i > 0) refs.current[i - 1]?.focus();
|
||||
}
|
||||
|
||||
const filled = vals.every(Boolean);
|
||||
|
||||
return (
|
||||
<OnboardingCard translucent>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<OnboardingHeading
|
||||
title="Confirm your email"
|
||||
lede={
|
||||
<>
|
||||
Enter the 6-digit code we sent to{" "}
|
||||
<span className="text-foreground">{email || "your inbox"}</span>.
|
||||
</>
|
||||
}
|
||||
center
|
||||
/>
|
||||
<div className="flex justify-center gap-2.5">
|
||||
{vals.map((v, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el;
|
||||
}}
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={v}
|
||||
onChange={(e) => onChange(i, e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown(i, e)}
|
||||
className="size-(--sz-52px) rounded-md border border-input bg-muted/30 text-center font-mono text-xl font-semibold text-foreground outline-none transition-shadow focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button size="lg" className={cn("w-full rounded-full")} disabled={!filled} onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Didn't get it?{" "}
|
||||
<span className="text-foreground underline-offset-2 hover:underline">Resend code</span>
|
||||
{" · "}
|
||||
<span className="text-foreground underline-offset-2 hover:underline">
|
||||
Use a different email
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
// Shared presentational primitives for the onboarding flow. Ported from the
|
||||
// prototype's hand-written CSS onto the repo's design tokens (semantic colors,
|
||||
// Tailwind v4). No backend logic lives here — these are pure view pieces.
|
||||
import type { ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConnectorOption } from "./onboarding-data";
|
||||
|
||||
/**
|
||||
* The centered card frame every onboarding step sits in.
|
||||
*
|
||||
* `translucent` renders the panel at 95% so an animated backdrop (the orbiting
|
||||
* paperclip on the auth + welcome screens) reads through it, per the design.
|
||||
*/
|
||||
export function OnboardingCard({
|
||||
children,
|
||||
className,
|
||||
translucent,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
translucent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-(--sz-560px) max-w-full rounded-xl border border-border px-8 py-10 sm:px-10 sm:py-11",
|
||||
translucent ? "bg-card/95" : "bg-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Display heading + supporting lede, ported from the prototype's hero type. */
|
||||
export function OnboardingHeading({
|
||||
title,
|
||||
lede,
|
||||
center,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
lede?: ReactNode;
|
||||
center?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-2", center && "text-center")}>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-foreground">{title}</h1>
|
||||
{lede ? (
|
||||
<p className="text-base leading-relaxed text-muted-foreground">{lede}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Segmented progress bar with "Step N of M" meta. `total` defaults to 3. */
|
||||
export function Stepper({ step, total = 3 }: { step: number; total?: number }) {
|
||||
return (
|
||||
<div className="mb-7 flex flex-col gap-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: total }, (_, i) => i + 1).map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className={cn(
|
||||
"h-(--sz-3px) flex-1 rounded-full transition-colors",
|
||||
s <= step ? "bg-foreground" : "bg-border",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Step {step} of {total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Selectable pill chip (mission suggestions). */
|
||||
export function Chip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full border px-3.5 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "border-foreground bg-accent text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Large selectable option card (first-task choices). */
|
||||
export function ChoiceCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3.5 rounded-md border bg-muted/30 p-4 text-left transition-all",
|
||||
selected
|
||||
? "border-foreground ring-(length:--rad-3) ring-foreground/10"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md border border-border bg-background text-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-semibold text-foreground">{title}</span>
|
||||
<span className="mt-1 block text-(length:--text-compact) leading-snug text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-5 shrink-0 text-green-500 transition-opacity",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single connector row with a colored glyph and connect/connected toggle. */
|
||||
export function ConnectorRow({
|
||||
connector,
|
||||
connected,
|
||||
onToggle,
|
||||
}: {
|
||||
connector: ConnectorOption;
|
||||
connected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3.5 py-3">
|
||||
<span
|
||||
className="grid size-(--sz-30px) shrink-0 place-items-center rounded-md text-(length:--text-compact) font-bold text-white"
|
||||
style={{
|
||||
background: connector.color,
|
||||
border: connector.border ? "1px solid var(--border)" : undefined,
|
||||
}}
|
||||
>
|
||||
{connector.glyph}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-foreground">{connector.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{connector.description}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"rounded-full border px-3.5 py-2 text-(length:--text-compact) font-medium transition-colors",
|
||||
connected
|
||||
? "cursor-default border-border text-muted-foreground"
|
||||
: "border-border text-foreground hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{connected ? "Connected" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import { X } from "lucide-react";
|
||||
import { stepMotion } from "./onboarding-motion";
|
||||
|
||||
/**
|
||||
* Full-screen animated shell shared by both onboarding flows. Owns the close
|
||||
* button and the single `AnimatePresence`/keyed `motion.div` crossfade, so both
|
||||
* flows animate step transitions identically. `stepKey` must change whenever the
|
||||
* rendered step changes (that's what drives the enter/exit).
|
||||
*/
|
||||
export function OnboardingScaffold({
|
||||
stepKey,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
stepKey: string;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<div className="fixed inset-0 z-50 flex flex-col overflow-y-auto bg-background">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute left-4 top-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
aria-label="Close onboarding"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-h-full w-full items-center justify-center px-5 py-20">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={stepKey} className="flex w-full justify-center" {...stepMotion}>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
// Orbiting 3D paperclip — the animated backdrop behind the onboarding auth
|
||||
// screens. Ported from the Paperclip graphic generator's 3D tab
|
||||
// (paperclip-gen.vercel.app) with its default settings plus auto-orbit on:
|
||||
// the same SVG-derived clip path, tube/cap geometry, "soft" gradient shader,
|
||||
// and orbit math, so the motif matches the generator exactly.
|
||||
//
|
||||
// three.js is heavy, so this module is loaded lazily (see OnboardingBackdrop).
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
// ── Generator defaults (3D tab) ───────────────────────────────────────────
|
||||
const TUBE_RADIUS = 0.225;
|
||||
const TUBE_SEG = 48; // radial segments
|
||||
const PATH_SEG = 300; // segments along the clip path
|
||||
const CAPS = true; // hemispherical caps
|
||||
const LIGHT_SOFT = 0;
|
||||
const LIGHT_ANGLE = 3.87;
|
||||
const EMISSIVE = 1;
|
||||
const CAM_DIST = 5;
|
||||
const FOV = 45;
|
||||
/** Auto-orbit speed. 0.5 was the generator default; slowed 25% to 0.375. */
|
||||
const ROT_SPEED = 0.375;
|
||||
/** Tilt oscillation rate. Slowed 25% alongside ROT_SPEED so the orbit's
|
||||
* rotation and tilt stay in the same relationship, just 25% calmer. */
|
||||
const TILT_RATE = 0.2625;
|
||||
/** Tilt amplitude of the orbit. Pace is set by the sin() frequency below, so
|
||||
* raising this swings further without speeding the motion up. */
|
||||
const AXIS_TILT = 1.2;
|
||||
/** Clip is scaled up 30% from the generator's default framing. */
|
||||
const CLIP_UPSCALE = 1.3;
|
||||
|
||||
// Colour: instead of the generator's "animate gradient" (which scrolls the
|
||||
// gradient ALONG the clip), the gradient stops hold their position and the two
|
||||
// stops slowly crossfade through this palette, so the whole clip cycles hue as
|
||||
// it orbits. Drawn from the brand agent-gradient stops, limited to the
|
||||
// saturated ones — the brand pastels (cream/peach/lavender) read as washed-out
|
||||
// grey against the dark backdrop.
|
||||
const PALETTE: Array<[string, string]> = [
|
||||
["#4fbcba", "#3aa35c"], // teal → green (the reference comp's colourway)
|
||||
["#3aa35c", "#f2d95f"], // green → yellow
|
||||
["#e3a21a", "#e94b27"], // amber → orange-red
|
||||
["#ee79a1", "#bd7ff0"], // pink → purple
|
||||
["#7eb6e3", "#3355ff"], // light blue → blue
|
||||
["#3355ff", "#cc3388"], // blue → magenta (generator default)
|
||||
];
|
||||
/** Seconds each palette pair holds before crossfading into the next. */
|
||||
const PALETTE_CYCLE_SECONDS = 9;
|
||||
/**
|
||||
* Static gradient position along the path (no scrolling — "animate gradient" is
|
||||
* off). Must stay 0: the shader wraps the gradient with mod(t, 1.0), and any
|
||||
* non-zero shift puts that wrap seam partway along the clip, which makes the
|
||||
* end caps (whose gradient-t is pinned to the path ends) sample across the seam
|
||||
* and read as a different colour from the tube beside them.
|
||||
*/
|
||||
const GRADIENT_SHIFT = 0;
|
||||
|
||||
// The clip motif: an SVG path sampled into a Catmull-Rom curve. Scale + center
|
||||
// come from the generator so proportions match.
|
||||
const CLIP_PATH_D =
|
||||
"M5.00146 4.99569V21.005C5.00146 22.1084 5.89689 23.0028 7.00146 23.0028C8.10603 23.0028 9.00147 22.1084 9.00147 21.005L9 4.99569C9 2.78893 7.20914 1 5 1C2.79086 1 1 2.78893 1 4.99569V21.005C1 24.3159 3.68695 27 7.00146 27C10.316 27 13.0029 24.3159 13.0029 21.005L13.0029 4.99569";
|
||||
const CLIP_CENTER = { cx: 7, cy: 14 };
|
||||
const CLIP_SCALE = 1 / 6.5;
|
||||
const CURVE_SAMPLES = 200;
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute float aPathT;
|
||||
attribute float aCapT; // -1.0 for tube verts; explicit gradient-t for cap verts
|
||||
varying vec3 vWorldNormal;
|
||||
varying float vPathT;
|
||||
varying float vCapT;
|
||||
void main() {
|
||||
vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
|
||||
vPathT = aPathT;
|
||||
vCapT = aCapT;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
#define MAX_COLORS 8
|
||||
uniform vec3 uColors[MAX_COLORS];
|
||||
uniform int uNColors;
|
||||
uniform float uShift;
|
||||
uniform float uSoftness;
|
||||
uniform vec3 uLightDir;
|
||||
uniform float uEmissive;
|
||||
varying vec3 vWorldNormal;
|
||||
varying float vPathT;
|
||||
varying float vCapT;
|
||||
|
||||
vec3 sampleGradAt(float t) {
|
||||
float n = float(uNColors);
|
||||
float nc = n;
|
||||
float pos = clamp(t * (n - 1.0), 0.0, n - 1.0001);
|
||||
int idx = int(pos);
|
||||
float f = smoothstep(0.0, 1.0, pos - float(idx));
|
||||
vec3 cols[MAX_COLORS];
|
||||
cols[0]=uColors[0];cols[1]=uColors[1];cols[2]=uColors[2];cols[3]=uColors[3];
|
||||
cols[4]=uColors[4];cols[5]=uColors[5];cols[6]=uColors[6];cols[7]=uColors[7];
|
||||
int i0 = int(mod(float(idx), nc));
|
||||
int i1 = int(mod(float(idx+1), nc));
|
||||
return mix(cols[i0], cols[i1], f);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float shiftT = uShift / (2.0 * 3.14159265);
|
||||
float rawT = (vCapT >= 0.0) ? vCapT : vPathT;
|
||||
float nCols = float(uNColors);
|
||||
float scaled = rawT * (nCols - 1.0) / nCols;
|
||||
float t = mod(scaled + shiftT, 1.0);
|
||||
vec3 baseColor = sampleGradAt(t);
|
||||
|
||||
vec3 nor = normalize(vWorldNormal);
|
||||
float wrap = mix(0.0, 0.6, uSoftness);
|
||||
float NdotL = dot(nor, normalize(uLightDir));
|
||||
float diff = smoothstep(0.0, 1.0, clamp((NdotL + wrap) / (1.0 + wrap), 0.0, 1.0));
|
||||
float ambient = mix(0.3, 0.6, uSoftness);
|
||||
vec3 lit = baseColor * (ambient + diff * (1.0 - ambient));
|
||||
lit = mix(lit, baseColor, uEmissive * 0.85);
|
||||
lit = pow(max(lit, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
gl_FragColor = vec4(lit, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Sample the clip SVG path into a centered, scaled Catmull-Rom curve. */
|
||||
function buildClipCurve(): THREE.CatmullRomCurve3 {
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(svgNS, "svg");
|
||||
svg.setAttribute("width", "0");
|
||||
svg.setAttribute("height", "0");
|
||||
const path = document.createElementNS(svgNS, "path");
|
||||
path.setAttribute("d", CLIP_PATH_D);
|
||||
svg.appendChild(path);
|
||||
document.body.appendChild(svg);
|
||||
|
||||
const total = path.getTotalLength();
|
||||
const points: THREE.Vector3[] = [];
|
||||
for (let i = 0; i <= CURVE_SAMPLES; i++) {
|
||||
const p = path.getPointAtLength((i / CURVE_SAMPLES) * total);
|
||||
points.push(
|
||||
new THREE.Vector3(
|
||||
(p.x - CLIP_CENTER.cx) * CLIP_SCALE,
|
||||
-(p.y - CLIP_CENTER.cy) * CLIP_SCALE,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
document.body.removeChild(svg);
|
||||
return new THREE.CatmullRomCurve3(points, false, "catmullrom", 0.1);
|
||||
}
|
||||
|
||||
/** Hemispherical end cap oriented along `normal`, tagged with its gradient t. */
|
||||
function buildCap(
|
||||
center: THREE.Vector3,
|
||||
normal: THREE.Vector3,
|
||||
radius: number,
|
||||
radialSeg: number,
|
||||
pathT: number,
|
||||
): THREE.BufferGeometry {
|
||||
const geo = new THREE.SphereGeometry(
|
||||
radius,
|
||||
radialSeg,
|
||||
Math.ceil(radialSeg / 2),
|
||||
0,
|
||||
Math.PI * 2,
|
||||
0,
|
||||
Math.PI / 2,
|
||||
);
|
||||
const count = geo.attributes.position.count;
|
||||
geo.setAttribute("aPathT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
|
||||
geo.setAttribute("aCapT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const quat = new THREE.Quaternion().setFromUnitVectors(up, normal.clone().normalize());
|
||||
geo.applyMatrix4(new THREE.Matrix4().makeRotationFromQuaternion(quat));
|
||||
geo.translate(center.x, center.y, center.z);
|
||||
return geo;
|
||||
}
|
||||
|
||||
export function PaperclipOrbit3D({ className }: { className?: string }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
// Respect reduced motion: render a single static frame instead of orbiting.
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
} catch {
|
||||
return; // No WebGL — the backdrop simply stays empty.
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
host.appendChild(renderer.domElement);
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
renderer.domElement.style.display = "block";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100);
|
||||
|
||||
// ── Material (generator's "soft" mode) ──
|
||||
// Two live stops; the render loop crossfades them through PALETTE.
|
||||
const uColors = Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1));
|
||||
const paletteColors = PALETTE.map(
|
||||
([a, b]) => [new THREE.Color(a), new THREE.Color(b)] as const,
|
||||
);
|
||||
const stopA = new THREE.Color();
|
||||
const stopB = new THREE.Color();
|
||||
// Stops are declared as [A, B, B] (nColors 3) rather than [A, B]: the
|
||||
// shader maps path position through `rawT * (n-1)/n`, so with 2 colours the
|
||||
// clip would only reach the A→B midpoint. The repeated final stop lets the
|
||||
// gradient complete A→B across the clip while keeping t inside [0,1) — no
|
||||
// mod() wrap, so the end caps still match the tube.
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColors: { value: uColors },
|
||||
uNColors: { value: 3 },
|
||||
uShift: { value: GRADIENT_SHIFT },
|
||||
uSoftness: { value: LIGHT_SOFT },
|
||||
uLightDir: {
|
||||
value: new THREE.Vector3(
|
||||
Math.cos(LIGHT_ANGLE) * 0.7,
|
||||
0.8,
|
||||
Math.sin(LIGHT_ANGLE) * 0.7,
|
||||
).normalize(),
|
||||
},
|
||||
uEmissive: { value: EMISSIVE },
|
||||
},
|
||||
vertexShader: VERTEX_SHADER,
|
||||
fragmentShader: FRAGMENT_SHADER,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
// ── Geometry: tube along the clip curve + hemispherical caps ──
|
||||
const curve = buildClipCurve();
|
||||
const group = new THREE.Group();
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
const tube = new THREE.TubeGeometry(curve, PATH_SEG, TUBE_RADIUS, TUBE_SEG, false);
|
||||
// TubeGeometry lays out (PATH_SEG+1) rings of (TUBE_SEG+1) verts: aPathT is
|
||||
// the ring's position along the path; aCapT -1 marks "not a cap".
|
||||
{
|
||||
const count = tube.attributes.position.count;
|
||||
const pathT = new Float32Array(count);
|
||||
const capT = new Float32Array(count).fill(-1);
|
||||
const ring = TUBE_SEG + 1;
|
||||
for (let i = 0; i < count; i++) pathT[i] = Math.floor(i / ring) / PATH_SEG;
|
||||
tube.setAttribute("aPathT", new THREE.BufferAttribute(pathT, 1));
|
||||
tube.setAttribute("aCapT", new THREE.BufferAttribute(capT, 1));
|
||||
}
|
||||
geometries.push(tube);
|
||||
|
||||
if (CAPS) {
|
||||
const pts = curve.getPoints(PATH_SEG);
|
||||
const startNormal = pts[1].clone().sub(pts[0]).normalize().negate();
|
||||
const endNormal = pts[pts.length - 1]
|
||||
.clone()
|
||||
.sub(pts[pts.length - 2])
|
||||
.normalize();
|
||||
geometries.push(buildCap(pts[0], startNormal, TUBE_RADIUS, TUBE_SEG, 0));
|
||||
geometries.push(buildCap(pts[pts.length - 1], endNormal, TUBE_RADIUS, TUBE_SEG, 1));
|
||||
}
|
||||
|
||||
geometries.forEach((geo) => group.add(new THREE.Mesh(geo, material)));
|
||||
group.scale.setScalar(CLIP_UPSCALE);
|
||||
scene.add(group);
|
||||
|
||||
const target = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
function resize() {
|
||||
const w = host!.clientWidth;
|
||||
const h = host!.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
|
||||
// ── Orbit + animated gradient (generator's loop, auto-orbit on) ──
|
||||
const start = performance.now();
|
||||
let raf = 0;
|
||||
function frame() {
|
||||
const t = reduceMotion ? 0 : (performance.now() - start) / 1000;
|
||||
const theta = t * ROT_SPEED * 0.25;
|
||||
const phi = Math.PI / 2 + Math.sin(t * TILT_RATE) * AXIS_TILT;
|
||||
camera.position.set(
|
||||
target.x + CAM_DIST * Math.sin(phi) * Math.sin(theta),
|
||||
target.y + CAM_DIST * Math.cos(phi),
|
||||
target.z + CAM_DIST * Math.sin(phi) * Math.cos(theta),
|
||||
);
|
||||
camera.lookAt(target);
|
||||
|
||||
// Slow colour cycle: ease between consecutive palette pairs so the whole
|
||||
// clip crossfades hue over time (the gradient itself stays put on the
|
||||
// path — the generator's scrolling "animate gradient" is off).
|
||||
const pos = (t / PALETTE_CYCLE_SECONDS) % paletteColors.length;
|
||||
const idx = Math.floor(pos);
|
||||
const raw = pos - idx;
|
||||
const f = raw * raw * (3 - 2 * raw); // smoothstep
|
||||
const from = paletteColors[idx];
|
||||
const to = paletteColors[(idx + 1) % paletteColors.length];
|
||||
// lerpHSL rotates hue instead of crossing through grey, so the clip stays
|
||||
// saturated mid-transition (plain RGB lerp muddies opposing hues).
|
||||
stopA.copy(from[0]).lerpHSL(to[0], f);
|
||||
stopB.copy(from[1]).lerpHSL(to[1], f);
|
||||
uColors[0].set(stopA.r, stopA.g, stopA.b);
|
||||
// Stops 1..7 all hold B (see the [A, B, B] note above).
|
||||
for (let i = 1; i < 8; i++) uColors[i].set(stopB.r, stopB.g, stopB.b);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
if (!reduceMotion) raf = requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
geometries.forEach((g) => g.dispose());
|
||||
material.dispose();
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode === host) host.removeChild(renderer.domElement);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={hostRef} className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export default PaperclipOrbit3D;
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
// Static option data for the onboarding flow, ported from the prototype
|
||||
// (paperclip-onboard/src/data.js). Connectors + models are presentational in
|
||||
// the first cut; wiring them to real integrations is a later refinement.
|
||||
import {
|
||||
DEFAULT_TASK_DESCRIPTION,
|
||||
DEFAULT_TASK_TITLE,
|
||||
} from "@/lib/onboarding-constants";
|
||||
|
||||
export const MISSION_CHIPS = [
|
||||
"Build a SaaS product",
|
||||
"Launch a marketplace",
|
||||
"Scale a content business",
|
||||
];
|
||||
|
||||
export const ROLE_OPTIONS = [
|
||||
"Chief of Staff",
|
||||
"Chief Technical Officer",
|
||||
"Head of Marketing",
|
||||
"Researcher",
|
||||
"Coder",
|
||||
"Designer",
|
||||
"Other",
|
||||
];
|
||||
|
||||
export const ROLE_ACRONYMS: Record<string, string> = {
|
||||
"Chief of Staff": "COS",
|
||||
"Chief Technical Officer": "CTO",
|
||||
"Head of Marketing": "HOM",
|
||||
Researcher: "RES",
|
||||
Coder: "CDR",
|
||||
Designer: "DES",
|
||||
Other: "OTH",
|
||||
};
|
||||
|
||||
export interface ConnectorOption {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Glyph background color. */
|
||||
color: string;
|
||||
/** Whether the glyph needs a border (for near-black glyphs). */
|
||||
border?: boolean;
|
||||
/** Short glyph text. */
|
||||
glyph: string;
|
||||
}
|
||||
|
||||
export const CONNECTORS: ConnectorOption[] = [
|
||||
{ name: "GitHub", description: "Code, PRs, issues", color: "#0a0a0a", border: true, glyph: "GH" },
|
||||
{ name: "Google", description: "Gmail, Drive, Calendar", color: "#4285f4", glyph: "G" },
|
||||
{ name: "Notion", description: "Docs and databases", color: "#0a0a0a", border: true, glyph: "N" },
|
||||
{ name: "Linear", description: "Issues and projects", color: "#5e6ad2", glyph: "L" },
|
||||
{ name: "Supabase", description: "Database and auth", color: "#3ecf8e", glyph: "S" },
|
||||
{ name: "Slack", description: "Team messaging", color: "#611f69", glyph: "#" },
|
||||
{ name: "Stripe", description: "Payments and billing", color: "#635bff", glyph: "$" },
|
||||
{ name: "Vercel", description: "Deploys and hosting", color: "#0a0a0a", border: true, glyph: "▲" },
|
||||
];
|
||||
|
||||
export type FirstTaskChoice = "hiring" | "strategy" | "custom";
|
||||
|
||||
/** Build the first-task title/description from the chosen option (shared by both flows). */
|
||||
export function firstTaskPayload(
|
||||
choice: FirstTaskChoice,
|
||||
custom: string,
|
||||
): { title: string; description: string } {
|
||||
switch (choice) {
|
||||
case "hiring":
|
||||
return { title: DEFAULT_TASK_TITLE, description: DEFAULT_TASK_DESCRIPTION };
|
||||
case "strategy":
|
||||
return {
|
||||
title: "Write a one-page team strategy",
|
||||
description:
|
||||
"You are the CEO. Turn the company mission into a one-page strategy: the goals, the bets you're making, and how you'll measure progress.",
|
||||
};
|
||||
case "custom":
|
||||
return {
|
||||
title: custom.trim() || "First task",
|
||||
description: custom.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// Shared motion constants for the onboarding flows (cloud + local). Extracted
|
||||
// from the original OnboardingFlow so both flow containers and the shared step
|
||||
// views animate identically. Reduced-motion is honored globally via
|
||||
// <MotionConfig reducedMotion="user"> in OnboardingScaffold.
|
||||
|
||||
/** Step crossfade easing (also reused by in-step reveals). */
|
||||
export const STEP_EASE = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
/** Per-step enter/exit crossfade used by the scaffold's keyed motion.div. */
|
||||
export const stepMotion = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -8 },
|
||||
transition: { duration: 0.28, ease: STEP_EASE },
|
||||
};
|
||||
|
||||
// Agent-step dashed-pill entrance: fades + scales up from 50% from its center
|
||||
// (no y offset, which would bias growth upward), with a spring bounce on
|
||||
// landing.
|
||||
export const CAPSULE_ENTER_DURATION = 1.0;
|
||||
export const capsuleMotion = {
|
||||
initial: { opacity: 0, scale: 0.5 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { type: "spring" as const, duration: CAPSULE_ENTER_DURATION, bounce: 0.4 },
|
||||
};
|
||||
|
||||
// Agent-step name/role reveal: the capsule slide-up (height growth) and the
|
||||
// label fade share this duration; the fade is staggered by 25% of it.
|
||||
export const PREVIEW_REVEAL_DURATION = 0.45;
|
||||
|
||||
// Step 2 → 3 capsule hand-off: on "Create" the capsule eases out (scale to
|
||||
// 50%, fade to 0) with the exiting step, then resurfaces on the task step —
|
||||
// scaling back to 100% on a spring so it lands into place, fade eased to match.
|
||||
// Exit duration mirrors the step transition so they travel together.
|
||||
export const capsuleHandoffExit = {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.28, ease: STEP_EASE },
|
||||
};
|
||||
export const capsuleHeroMotion = {
|
||||
initial: { scale: 0.5, opacity: 0 },
|
||||
animate: { scale: 1, opacity: 1 },
|
||||
transition: {
|
||||
// Softer spring = a slower, more noticeable scale-up that still lands with
|
||||
// a natural settle; fade lengthened to travel with it.
|
||||
scale: { type: "spring" as const, stiffness: 150, damping: 16 },
|
||||
opacity: { duration: 0.55, ease: STEP_EASE },
|
||||
},
|
||||
};
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { listUIAdapters } from "@/adapters";
|
||||
import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
|
||||
import { isVisualAdapterChoice } from "@/adapters/metadata";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AgentCapsule } from "../../AgentCapsule";
|
||||
import { OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
|
||||
import { FooterNav } from "../FooterNav";
|
||||
import { AgentPreview } from "../AgentPreview";
|
||||
import { capsuleHandoffExit, capsuleHeroMotion } from "../onboarding-motion";
|
||||
|
||||
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
|
||||
function visualAdapters() {
|
||||
return listUIAdapters()
|
||||
.filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && isVisualAdapterChoice(a.type))
|
||||
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }))
|
||||
.filter((a) => !a.comingSoon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-flow only: pick the model/adapter the first agent runs on. Recommended
|
||||
* options are surfaced as cards; the rest are grouped in an "Additional models"
|
||||
* dropdown. Selecting from either sets the same adapterType.
|
||||
*/
|
||||
export function AdapterStep({
|
||||
adapterType,
|
||||
onAdapterChange,
|
||||
agentName,
|
||||
agentRole,
|
||||
onBack,
|
||||
onNext,
|
||||
loading,
|
||||
step,
|
||||
total,
|
||||
}: {
|
||||
adapterType: string;
|
||||
onAdapterChange: (type: string) => void;
|
||||
agentName: string;
|
||||
agentRole: string;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
loading?: boolean;
|
||||
step: number;
|
||||
total?: number;
|
||||
}) {
|
||||
const { recommended, additional } = useMemo(() => {
|
||||
const all = visualAdapters();
|
||||
return {
|
||||
recommended: all.filter((a) => a.recommended),
|
||||
// Top handful of other models beyond the recommended ones.
|
||||
additional: all.filter((a) => !a.recommended).slice(0, 5),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The dropdown reflects the selection only when it isn't one of the cards.
|
||||
const additionalValue = additional.some((a) => a.type === adapterType) ? adapterType : undefined;
|
||||
|
||||
return (
|
||||
<OnboardingCard>
|
||||
<Stepper step={step} total={total} />
|
||||
<div className="space-y-6">
|
||||
{/* Carried-over outlined capsule from the agent step: enters with the
|
||||
same hero scale/fade as the task pill and hands off (scale down) on
|
||||
exit, but stays "configured" (outlined) — no coming-alive here. */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<motion.div {...capsuleHeroMotion} exit={capsuleHandoffExit}>
|
||||
<AgentCapsule state="configured" gradient={5} glow="blue" size="md" />
|
||||
</motion.div>
|
||||
<AgentPreview agentName={agentName} agentRole={agentRole} />
|
||||
</div>
|
||||
<OnboardingHeading
|
||||
title="Connect a model"
|
||||
lede="What model would you like your first agent to use? You can choose different models when creating additional agents."
|
||||
center
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{recommended.map((opt) => {
|
||||
const selected = adapterType === opt.type;
|
||||
return (
|
||||
<button
|
||||
key={opt.type}
|
||||
type="button"
|
||||
onClick={() => onAdapterChange(opt.type)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
|
||||
selected
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="absolute -top-1.5 right-1.5 rounded-full bg-green-500 px-1.5 py-0.5 text-(length:--text-nano) font-semibold leading-none text-white">
|
||||
Recommended
|
||||
</span>
|
||||
<opt.icon className="size-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-(length:--text-nano) text-muted-foreground">
|
||||
{opt.description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{additional.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Additional models
|
||||
</span>
|
||||
<Select value={additionalValue} onValueChange={onAdapterChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose another model…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{additional.map((opt) => (
|
||||
<SelectItem key={opt.type} value={opt.type}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FooterNav
|
||||
onBack={onBack}
|
||||
primaryLabel="Connect now"
|
||||
primaryDisabled={!adapterType}
|
||||
loading={loading}
|
||||
loadingLabel="Bringing to life..."
|
||||
onPrimary={onNext}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { motion } from "motion/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AgentCapsule } from "../../AgentCapsule";
|
||||
import { ROLE_OPTIONS } from "../onboarding-data";
|
||||
import { OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
|
||||
import { FooterNav } from "../FooterNav";
|
||||
import { AgentPreview } from "../AgentPreview";
|
||||
import { capsuleHandoffExit, capsuleMotion } from "../onboarding-motion";
|
||||
|
||||
/** Create-your-first-agent step: role select + optional name, with the capsule. */
|
||||
export function AgentStep({
|
||||
agentRole,
|
||||
agentName,
|
||||
onRoleChange,
|
||||
onNameChange,
|
||||
onBack,
|
||||
onNext,
|
||||
loading,
|
||||
step,
|
||||
total,
|
||||
primaryLabel = "Create",
|
||||
loadingLabel = "Creating...",
|
||||
}: {
|
||||
agentRole: string;
|
||||
agentName: string;
|
||||
onRoleChange: (value: string) => void;
|
||||
onNameChange: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
loading?: boolean;
|
||||
step: number;
|
||||
total?: number;
|
||||
/** CTA label — cloud hires here ("Create"); local advances to the adapter step ("Next"). */
|
||||
primaryLabel?: string;
|
||||
loadingLabel?: string;
|
||||
}) {
|
||||
const previewVisible = Boolean(agentName || agentRole);
|
||||
return (
|
||||
<OnboardingCard>
|
||||
<Stepper step={step} total={total} />
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<motion.div {...capsuleMotion} exit={capsuleHandoffExit}>
|
||||
<AgentCapsule
|
||||
state={previewVisible ? "configured" : "slot"}
|
||||
strokeDraw
|
||||
gradient={5}
|
||||
glow="blue"
|
||||
size="md"
|
||||
/>
|
||||
</motion.div>
|
||||
<AgentPreview agentName={agentName} agentRole={agentRole} />
|
||||
</div>
|
||||
<OnboardingHeading title="Create your first agent" center />
|
||||
<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>
|
||||
<Select value={agentRole || undefined} onValueChange={onRoleChange}>
|
||||
<SelectTrigger id="onboarding-agent-role" className="w-full">
|
||||
<SelectValue placeholder="Select a role…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="onboarding-agent-name">
|
||||
Name <span className="font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="onboarding-agent-name"
|
||||
placeholder="Name"
|
||||
value={agentName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FooterNav
|
||||
onBack={onBack}
|
||||
primaryLabel={primaryLabel}
|
||||
primaryDisabled={!agentRole.trim()}
|
||||
loading={loading}
|
||||
loadingLabel={loadingLabel}
|
||||
onPrimary={onNext}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MISSION_CHIPS } from "../onboarding-data";
|
||||
import { Chip, OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
|
||||
import { FooterNav } from "../FooterNav";
|
||||
|
||||
/** Company name + mission step. */
|
||||
export function CompanyStep({
|
||||
companyName,
|
||||
onCompanyNameChange,
|
||||
mission,
|
||||
onMissionChange,
|
||||
onBack,
|
||||
onNext,
|
||||
loading,
|
||||
step,
|
||||
total,
|
||||
}: {
|
||||
companyName: string;
|
||||
onCompanyNameChange: (value: string) => void;
|
||||
mission: string;
|
||||
onMissionChange: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
loading?: boolean;
|
||||
step: number;
|
||||
total?: number;
|
||||
}) {
|
||||
const [activeChip, setActiveChip] = useState<string | null>(null);
|
||||
return (
|
||||
<OnboardingCard>
|
||||
<Stepper step={step} total={total} />
|
||||
<div className="space-y-6">
|
||||
<OnboardingHeading
|
||||
title="What is the name of your company or team?"
|
||||
lede="This will be the name of your Paperclip organization — choose something your team will recognize."
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="onboarding-company-name">Name</Label>
|
||||
<Input
|
||||
id="onboarding-company-name"
|
||||
placeholder="e.g. Northwind Labs"
|
||||
value={companyName}
|
||||
onChange={(e) => onCompanyNameChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="onboarding-mission">What is the mission?</Label>
|
||||
<Textarea
|
||||
id="onboarding-mission"
|
||||
className="min-h-(--sz-88px)"
|
||||
placeholder="This could be your company mission, team goal, or desired outcome."
|
||||
value={mission}
|
||||
onChange={(e) => {
|
||||
onMissionChange(e.target.value);
|
||||
setActiveChip(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MISSION_CHIPS.map((chip) => (
|
||||
<Chip
|
||||
key={chip}
|
||||
label={chip}
|
||||
active={activeChip === chip}
|
||||
onClick={() => {
|
||||
onMissionChange(chip);
|
||||
setActiveChip(chip);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FooterNav
|
||||
onBack={onBack}
|
||||
primaryLabel="Next"
|
||||
primaryDisabled={!companyName.trim() || !mission.trim()}
|
||||
loading={loading}
|
||||
loadingLabel="Creating..."
|
||||
onPrimary={onNext}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { OnboardingCard, OnboardingHeading } from "../OnboardingPrimitives";
|
||||
|
||||
/**
|
||||
* Local-flow only: a gentle, OPTIONAL email ask before the numbered steps, with
|
||||
* an explicit privacy assurance. Both Continue and Skip advance; the email is
|
||||
* collected into container state (persistence is out of scope for now).
|
||||
*/
|
||||
export function EmailStep({
|
||||
email,
|
||||
onEmailChange,
|
||||
onContinue,
|
||||
onSkip,
|
||||
}: {
|
||||
email: string;
|
||||
onEmailChange: (value: string) => void;
|
||||
onContinue: () => void;
|
||||
onSkip: () => void;
|
||||
}) {
|
||||
return (
|
||||
<OnboardingCard>
|
||||
<div className="space-y-6">
|
||||
<OnboardingHeading
|
||||
title="Want product updates?"
|
||||
lede="Leave an email and we'll let you know about meaningful releases. Totally optional."
|
||||
center
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="onboarding-email">
|
||||
Email <span className="font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="onboarding-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => onEmailChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3.5 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<ShieldCheck className="mt-px size-4 shrink-0 text-foreground/70" />
|
||||
<span>
|
||||
We'll never use this for marketing or share it with third parties — it's only for
|
||||
the occasional product update.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button size="lg" className="w-full rounded-full" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="rounded-full" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { Mail, PlusCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Welcome / choose-a-path screen (unnumbered). "Set up" advances the flow (the
|
||||
* container decides the next step); "Join an existing team" is stubbed.
|
||||
*
|
||||
* Cards are translucent so the orbiting-paperclip backdrop reads through them
|
||||
* (local flow, which skips the auth screens and leads with this screen).
|
||||
*/
|
||||
export function StartStep({ onSetup }: { onSetup: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-9">
|
||||
<h1 className="text-center text-4xl font-semibold leading-10 tracking-tight text-foreground">
|
||||
Welcome to Paperclip!
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-start justify-center gap-6">
|
||||
<StartOption
|
||||
icon={<PlusCircle className="size-11" strokeWidth={1.2} />}
|
||||
title="Set up Paperclip for your company or team"
|
||||
description="Create a new organization, build your first agent, and assign its first task."
|
||||
onClick={onSetup}
|
||||
/>
|
||||
<StartOption
|
||||
icon={<Mail className="size-11" strokeWidth={1.2} />}
|
||||
title="Join an existing company or team"
|
||||
description="Have an invite? Enter your team's join code to come aboard."
|
||||
onClick={() => {}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A square choice tile: icon, title and description all inside the card. */
|
||||
function StartOption({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-(--sz-360px) w-(--sz-360px) max-w-full flex-col items-center justify-center gap-6 rounded-2xl border border-border bg-card/95 px-6 text-center transition-colors",
|
||||
// Both tiles render identically per the comp — the stubbed "join" tile
|
||||
// is inert (disabled) but must NOT be dimmed, or it reads as broken and
|
||||
// lets the backdrop bleed through it.
|
||||
"hover:border-muted-foreground disabled:hover:border-border",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="flex w-72 max-w-full flex-col gap-2">
|
||||
<span className="text-lg font-semibold leading-7 text-foreground">{title}</span>
|
||||
<span className="text-sm leading-relaxed text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Building2, Pencil, UserPlus } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentCapsule } from "../../AgentCapsule";
|
||||
import type { FirstTaskChoice } from "../onboarding-data";
|
||||
import { ChoiceCard, OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
|
||||
import { FooterNav } from "../FooterNav";
|
||||
import { AgentPreview } from "../AgentPreview";
|
||||
import { capsuleHeroMotion, STEP_EASE } from "../onboarding-motion";
|
||||
|
||||
/** Assign-the-first-task step: three choice cards + a custom-task reveal. */
|
||||
export function TaskStep({
|
||||
agentName,
|
||||
agentRole,
|
||||
taskChoice,
|
||||
onSelectChoice,
|
||||
customTask,
|
||||
onCustomTaskChange,
|
||||
onBack,
|
||||
onGetStarted,
|
||||
loading,
|
||||
error,
|
||||
step,
|
||||
total,
|
||||
}: {
|
||||
agentName: string;
|
||||
agentRole: string;
|
||||
taskChoice: FirstTaskChoice | null;
|
||||
onSelectChoice: (choice: FirstTaskChoice) => void;
|
||||
customTask: string;
|
||||
onCustomTaskChange: (value: string) => void;
|
||||
onBack: () => void;
|
||||
onGetStarted: () => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
step: number;
|
||||
total?: number;
|
||||
}) {
|
||||
// Sentence-initial in the lede, so the fallback is capitalized.
|
||||
const agentLabel = agentName || agentRole || "Your agent";
|
||||
return (
|
||||
<OnboardingCard>
|
||||
<Stepper step={step} total={total} />
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<motion.div {...capsuleHeroMotion}>
|
||||
<AgentCapsule
|
||||
state="online"
|
||||
gradient={5}
|
||||
glow="blue"
|
||||
size="md"
|
||||
// "Coming alive" (radial fill + outline carryover-fade) runs 2×
|
||||
// the default reveal duration here for a hero beat.
|
||||
style={{ ["--agent-cap-reveal-duration"]: "2.8s" } as React.CSSProperties}
|
||||
/>
|
||||
</motion.div>
|
||||
<AgentPreview agentName={agentName} agentRole={agentRole} />
|
||||
</div>
|
||||
<OnboardingHeading
|
||||
title="Assign your agent a first task"
|
||||
lede={<>Where should we start? {agentLabel} is ready for instructions.</>}
|
||||
center
|
||||
/>
|
||||
{/* The reveal sits OUTSIDE the gap-3 flex and owns its own top spacing
|
||||
(pt-3), so collapsing to height 0 leaves no phantom flex gap to snap
|
||||
away on unmount — the collapse mirrors the expand exactly. overflow
|
||||
is hidden only while animating and visible at rest so the textarea's
|
||||
focus ring isn't clipped. */}
|
||||
<div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ChoiceCard
|
||||
icon={<UserPlus className="size-5" />}
|
||||
title="Create a hiring plan"
|
||||
description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
|
||||
selected={taskChoice === "hiring"}
|
||||
onClick={() => onSelectChoice("hiring")}
|
||||
/>
|
||||
<ChoiceCard
|
||||
icon={<Building2 className="size-5" />}
|
||||
title="Write a team strategy doc"
|
||||
description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
|
||||
selected={taskChoice === "strategy"}
|
||||
onClick={() => onSelectChoice("strategy")}
|
||||
/>
|
||||
<ChoiceCard
|
||||
icon={<Pencil className="size-5" />}
|
||||
title="Write your own task…"
|
||||
description="Describe anything else you want done first."
|
||||
selected={taskChoice === "custom"}
|
||||
onClick={() => onSelectChoice("custom")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{taskChoice === "custom" && (
|
||||
<motion.div
|
||||
key="custom-task-input"
|
||||
initial={{ height: 0, opacity: 0, overflow: "hidden" }}
|
||||
animate={{
|
||||
height: "auto",
|
||||
opacity: 1,
|
||||
transitionEnd: { overflow: "visible" },
|
||||
}}
|
||||
exit={{ height: 0, opacity: 0, overflow: "hidden" }}
|
||||
transition={{ duration: 0.3, ease: STEP_EASE }}
|
||||
>
|
||||
<div className="pt-3">
|
||||
<Textarea
|
||||
autoFocus
|
||||
className="min-h-(--sz-88px)"
|
||||
placeholder="Describe the first task…"
|
||||
value={customTask}
|
||||
onChange={(e) => onCustomTaskChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<FooterNav
|
||||
onBack={onBack}
|
||||
primaryLabel="Get started"
|
||||
primaryDisabled={!taskChoice || (taskChoice === "custom" && !customTask.trim())}
|
||||
loading={loading}
|
||||
loadingLabel="Launching..."
|
||||
onPrimary={onGetStarted}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The hook only needs a company id and the two agent endpoints for these cases;
|
||||
// everything else is mocked so the probe-cache logic is exercised in isolation.
|
||||
const testEnvironment = vi.hoisted(() => vi.fn());
|
||||
const hire = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ setSelectedCompanyId: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
testEnvironment,
|
||||
hire,
|
||||
instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })),
|
||||
saveInstructionsFile: vi.fn(async () => undefined),
|
||||
update: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api/companies", () => ({ companiesApi: { create: vi.fn() } }));
|
||||
vi.mock("@/api/goals", () => ({ goalsApi: { create: vi.fn() } }));
|
||||
vi.mock("@/api/approvals", () => ({ approvalsApi: { approve: vi.fn() } }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { create: vi.fn() } }));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: { create: vi.fn(), list: vi.fn() } }));
|
||||
|
||||
import { useOnboardingFlow, type OnboardingFlow } from "./useOnboardingFlow";
|
||||
|
||||
const INSTRUCTIONS = {
|
||||
companyName: "Acme",
|
||||
companyGoal: "Build things",
|
||||
growPath: false,
|
||||
growWorkflows: "",
|
||||
growPainPoints: "",
|
||||
growAutomate: "",
|
||||
q1: "",
|
||||
q2: "",
|
||||
q3: "",
|
||||
q4: "",
|
||||
};
|
||||
|
||||
function adapter(adapterType: string) {
|
||||
return { adapterType, model: "", command: "", args: "", url: "" };
|
||||
}
|
||||
|
||||
describe("useOnboardingFlow adapter environment probe", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
let flow: OnboardingFlow;
|
||||
|
||||
function Probe() {
|
||||
flow = useOnboardingFlow({ createdCompanyId: "company-1" });
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root!.render(<Probe />);
|
||||
});
|
||||
testEnvironment.mockResolvedValue({ status: "pass" });
|
||||
hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("re-probes when the adapter changed since the cached probe ran", async () => {
|
||||
// The local flow lets the user pick a different adapter and retry after a
|
||||
// failed hire. The first adapter's verdict must not stand in for the second.
|
||||
await act(async () => {
|
||||
await flow.runAdapterEnvironmentTest(adapter("claude_local"));
|
||||
});
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(1);
|
||||
|
||||
hire.mockRejectedValueOnce(new Error("hire failed"));
|
||||
await act(async () => {
|
||||
await flow.hireLeadAgent({
|
||||
agentName: "COS",
|
||||
adapter: adapter("claude_local"),
|
||||
instructions: INSTRUCTIONS,
|
||||
requireEnvProbe: true,
|
||||
});
|
||||
});
|
||||
// Same adapter — the cached probe is reused rather than re-run.
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await flow.hireLeadAgent({
|
||||
agentName: "COS",
|
||||
adapter: adapter("codex_local"),
|
||||
instructions: INSTRUCTIONS,
|
||||
requireEnvProbe: true,
|
||||
});
|
||||
});
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(2);
|
||||
expect(testEnvironment.mock.calls[1][1]).toBe("codex_local");
|
||||
// The probed config and the hired config are the same object of record.
|
||||
expect(hire.mock.calls[1][1].adapterConfig).toEqual(
|
||||
testEnvironment.mock.calls[1][2].adapterConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it("probes when no cached result exists", async () => {
|
||||
await act(async () => {
|
||||
await flow.hireLeadAgent({
|
||||
agentName: "COS",
|
||||
adapter: adapter("claude_local"),
|
||||
instructions: INSTRUCTIONS,
|
||||
requireEnvProbe: true,
|
||||
});
|
||||
});
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clearAdapterEnvResult forces the next hire to re-probe", async () => {
|
||||
await act(async () => {
|
||||
await flow.runAdapterEnvironmentTest(adapter("claude_local"));
|
||||
});
|
||||
act(() => {
|
||||
flow.clearAdapterEnvResult();
|
||||
});
|
||||
expect(flow.adapterEnvResult).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await flow.hireLeadAgent({
|
||||
agentName: "COS",
|
||||
adapter: adapter("claude_local"),
|
||||
instructions: INSTRUCTIONS,
|
||||
requireEnvProbe: true,
|
||||
});
|
||||
});
|
||||
expect(testEnvironment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not probe at all when requireEnvProbe is false (cloud flow)", async () => {
|
||||
await act(async () => {
|
||||
await flow.hireLeadAgent({
|
||||
agentName: "COS",
|
||||
adapter: adapter("claude_local"),
|
||||
instructions: INSTRUCTIONS,
|
||||
requireEnvProbe: false,
|
||||
});
|
||||
});
|
||||
expect(testEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
// Backend orchestration for onboarding, extracted from OnboardingWizard so a
|
||||
// new presentational onboarding flow can reuse the exact company/goal/agent/
|
||||
// issue creation logic without duplicating API calls or query invalidation.
|
||||
//
|
||||
// This hook owns the "created entity" ids and the adapter-environment probe
|
||||
// state; it does NOT own user-entered form state (company name, mission, agent
|
||||
// name, adapter selection) — callers pass those into the action functions. It
|
||||
// also does not navigate: launchFirstTask returns the target company prefix and
|
||||
// lets the caller route, keeping routing a UI concern.
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { companiesApi } from "@/api/companies";
|
||||
import { goalsApi } from "@/api/goals";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { approvalsApi } from "@/api/approvals";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { parseOnboardingGoalInput } from "@/lib/onboarding-goal";
|
||||
import { composeCeoInstructions } from "@/lib/ceo-instructions";
|
||||
import { buildNewAgentRuntimeConfig } from "@/lib/new-agent-runtime-config";
|
||||
import {
|
||||
buildOnboardingIssuePayload,
|
||||
buildOnboardingProjectPayload,
|
||||
selectDefaultCompanyGoalId,
|
||||
selectReusableOnboardingProject,
|
||||
} from "@/lib/onboarding-launch";
|
||||
import {
|
||||
buildOnboardingAdapterConfig,
|
||||
type OnboardingAdapterConfigInput,
|
||||
} from "@/lib/onboarding-adapter-config";
|
||||
import {
|
||||
DEFAULT_TASK_DESCRIPTION,
|
||||
DEFAULT_TASK_TITLE,
|
||||
} from "@/lib/onboarding-constants";
|
||||
|
||||
/** Adapter selection inputs shared by the env probe and the hire action. */
|
||||
export type AdapterInput = Omit<OnboardingAdapterConfigInput, "forceUnsetAnthropicApiKey">;
|
||||
|
||||
/** Context used to seed the lead agent's instructions file. */
|
||||
export interface OnboardingInstructionsContext {
|
||||
companyName: string;
|
||||
companyGoal: string;
|
||||
growPath: boolean;
|
||||
growWorkflows: string;
|
||||
growPainPoints: string;
|
||||
growAutomate: string;
|
||||
q1: string;
|
||||
q2: string;
|
||||
q3: string;
|
||||
q4: string;
|
||||
}
|
||||
|
||||
export interface HireLeadAgentInput {
|
||||
agentName: string;
|
||||
adapter: AdapterInput;
|
||||
instructions: OnboardingInstructionsContext;
|
||||
/**
|
||||
* When true (local adapters), require a successful adapter-environment probe
|
||||
* before hiring. Mirrors the wizard: hire is aborted only if the probe cannot
|
||||
* run at all (returns null), not if it returns a fail status.
|
||||
*/
|
||||
requireEnvProbe: boolean;
|
||||
}
|
||||
|
||||
export interface CreatedOnboardingEntities {
|
||||
createdCompanyId: string | null;
|
||||
createdCompanyPrefix: string | null;
|
||||
createdAgentId: string | null;
|
||||
createdCompanyGoalId: string | null;
|
||||
createdProjectId: string | null;
|
||||
createdIssueRef: string | null;
|
||||
}
|
||||
|
||||
export function useOnboardingFlow(initial?: Partial<CreatedOnboardingEntities>) {
|
||||
const queryClient = useQueryClient();
|
||||
const { setSelectedCompanyId } = useCompany();
|
||||
|
||||
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
|
||||
initial?.createdCompanyId ?? null,
|
||||
);
|
||||
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<string | null>(
|
||||
initial?.createdCompanyPrefix ?? null,
|
||||
);
|
||||
const [createdAgentId, setCreatedAgentId] = useState<string | null>(
|
||||
initial?.createdAgentId ?? null,
|
||||
);
|
||||
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
|
||||
initial?.createdCompanyGoalId ?? null,
|
||||
);
|
||||
const [createdProjectId, setCreatedProjectId] = useState<string | null>(
|
||||
initial?.createdProjectId ?? null,
|
||||
);
|
||||
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(
|
||||
initial?.createdIssueRef ?? null,
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [adapterEnvResult, setAdapterEnvResult] =
|
||||
useState<AdapterEnvironmentTestResult | null>(null);
|
||||
const [adapterEnvError, setAdapterEnvError] = useState<string | null>(null);
|
||||
const [adapterEnvLoading, setAdapterEnvLoading] = useState(false);
|
||||
const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] = useState(false);
|
||||
const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
|
||||
/**
|
||||
* Identifies the adapter selection that produced `adapterEnvResult`. The local
|
||||
* flow lets the user pick a different adapter and retry after a failed hire,
|
||||
* so a cached probe result is only evidence about the adapter it actually
|
||||
* ran against — see `hireLeadAgent`.
|
||||
*/
|
||||
const [adapterEnvProbedKey, setAdapterEnvProbedKey] = useState<string | null>(null);
|
||||
|
||||
function buildAdapterConfig(adapter: AdapterInput): Record<string, unknown> {
|
||||
return buildOnboardingAdapterConfig({ ...adapter, forceUnsetAnthropicApiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a probe: the adapter type plus the exact config posted
|
||||
* to the environment-test endpoint. `buildOnboardingAdapterConfig` builds its
|
||||
* object in a fixed key order for a given adapter, so `JSON.stringify` is
|
||||
* stable across calls.
|
||||
*/
|
||||
function adapterProbeKey(
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
): string {
|
||||
return JSON.stringify([adapterType, adapterConfig]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget any cached probe result. Callers invoke this when the user changes
|
||||
* the adapter selection, so neither the UI nor `hireLeadAgent` keeps showing
|
||||
* or trusting a verdict about the adapter that is no longer selected.
|
||||
*/
|
||||
function clearAdapterEnvResult(): void {
|
||||
setAdapterEnvResult(null);
|
||||
setAdapterEnvError(null);
|
||||
setAdapterEnvProbedKey(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the company + its company-level goal from the mission. Guarded so
|
||||
* calling it again after a company exists is a no-op that returns the
|
||||
* already-created ids.
|
||||
*/
|
||||
async function createCompanyAndGoal(input: {
|
||||
companyName: string;
|
||||
companyGoal: string;
|
||||
}): Promise<{ companyId: string; companyPrefix: string; goalId: string | null } | null> {
|
||||
if (createdCompanyId) {
|
||||
return {
|
||||
companyId: createdCompanyId,
|
||||
companyPrefix: createdCompanyPrefix ?? "",
|
||||
goalId: createdCompanyGoalId,
|
||||
};
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const company = await companiesApi.create({ name: input.companyName.trim() });
|
||||
setCreatedCompanyId(company.id);
|
||||
setCreatedCompanyPrefix(company.issuePrefix);
|
||||
setSelectedCompanyId(company.id);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
|
||||
const parsedGoal = parseOnboardingGoalInput(input.companyGoal);
|
||||
const goal = await goalsApi.create(company.id, {
|
||||
title: parsedGoal.title,
|
||||
...(parsedGoal.description ? { description: parsedGoal.description } : {}),
|
||||
level: "company",
|
||||
status: "active",
|
||||
});
|
||||
setCreatedCompanyGoalId(goal.id);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(company.id) });
|
||||
|
||||
return { companyId: company.id, companyPrefix: company.issuePrefix, goalId: goal.id };
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create company");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAdapterEnvironmentTest(
|
||||
adapter: AdapterInput,
|
||||
adapterConfigOverride?: Record<string, unknown>,
|
||||
): Promise<AdapterEnvironmentTestResult | null> {
|
||||
if (!createdCompanyId) {
|
||||
setAdapterEnvError(
|
||||
"Create or select a company before testing adapter environment.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
setAdapterEnvLoading(true);
|
||||
setAdapterEnvError(null);
|
||||
const adapterConfig = adapterConfigOverride ?? buildAdapterConfig(adapter);
|
||||
try {
|
||||
const result = await agentsApi.testEnvironment(createdCompanyId, adapter.adapterType, {
|
||||
adapterConfig,
|
||||
});
|
||||
setAdapterEnvResult(result);
|
||||
setAdapterEnvProbedKey(adapterProbeKey(adapter.adapterType, adapterConfig));
|
||||
return result;
|
||||
} catch (err) {
|
||||
setAdapterEnvError(
|
||||
err instanceof Error ? err.message : "Adapter environment test failed",
|
||||
);
|
||||
setAdapterEnvResult(null);
|
||||
setAdapterEnvProbedKey(null);
|
||||
return null;
|
||||
} finally {
|
||||
setAdapterEnvLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear ANTHROPIC_API_KEY in the adapter config and re-probe. Mirrors the
|
||||
* wizard's remediation when a stray key overrides the Claude subscription.
|
||||
*/
|
||||
async function unsetAnthropicApiKeyAndRetry(adapter: AdapterInput): Promise<void> {
|
||||
if (!createdCompanyId || unsetAnthropicLoading) return;
|
||||
setUnsetAnthropicLoading(true);
|
||||
setError(null);
|
||||
setAdapterEnvError(null);
|
||||
setForceUnsetAnthropicApiKey(true);
|
||||
|
||||
const configWithUnset = (() => {
|
||||
const config = buildAdapterConfig(adapter);
|
||||
const env =
|
||||
typeof config.env === "object" &&
|
||||
config.env !== null &&
|
||||
!Array.isArray(config.env)
|
||||
? { ...(config.env as Record<string, unknown>) }
|
||||
: {};
|
||||
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
|
||||
config.env = env;
|
||||
return config;
|
||||
})();
|
||||
|
||||
try {
|
||||
if (createdAgentId) {
|
||||
await agentsApi.update(createdAgentId, { adapterConfig: configWithUnset }, createdCompanyId);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
|
||||
}
|
||||
|
||||
const result = await runAdapterEnvironmentTest(adapter, configWithUnset);
|
||||
if (result?.status === "fail") {
|
||||
setError(
|
||||
"Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to unset ANTHROPIC_API_KEY and retry.",
|
||||
);
|
||||
} finally {
|
||||
setUnsetAnthropicLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hire the lead agent (role: ceo), auto-approve any board approval, and seed
|
||||
* its instructions file. Guarded so a second call after an agent exists is a
|
||||
* no-op. Returns null on failure (error is set) or when a required env probe
|
||||
* cannot run.
|
||||
*/
|
||||
async function hireLeadAgent(
|
||||
input: HireLeadAgentInput,
|
||||
): Promise<{ agentId: string } | null> {
|
||||
if (!createdCompanyId) return null;
|
||||
if (createdAgentId) return { agentId: createdAgentId };
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const adapterConfig = buildAdapterConfig(input.adapter);
|
||||
try {
|
||||
if (input.requireEnvProbe) {
|
||||
// Reuse the cached probe only when it ran against this exact adapter
|
||||
// selection. After a failed hire the user can go back and pick a
|
||||
// different adapter, and the previous adapter's verdict says nothing
|
||||
// about the new one.
|
||||
const cacheHit =
|
||||
adapterEnvResult !== null &&
|
||||
adapterEnvProbedKey === adapterProbeKey(input.adapter.adapterType, adapterConfig);
|
||||
const result = cacheHit
|
||||
? adapterEnvResult
|
||||
: await runAdapterEnvironmentTest(input.adapter, adapterConfig);
|
||||
if (!result) return null;
|
||||
}
|
||||
|
||||
const hire = await agentsApi.hire(createdCompanyId, {
|
||||
name: input.agentName.trim(),
|
||||
role: "ceo",
|
||||
adapterType: input.adapter.adapterType,
|
||||
adapterConfig,
|
||||
runtimeConfig: buildNewAgentRuntimeConfig(),
|
||||
});
|
||||
if (hire.approval) {
|
||||
await approvalsApi.approve(
|
||||
hire.approval.id,
|
||||
"Approved during onboarding first-agent setup.",
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.approvals.list(createdCompanyId),
|
||||
});
|
||||
}
|
||||
const agent = hire.agent;
|
||||
setCreatedAgentId(agent.id);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
|
||||
|
||||
// Seed the CEO's instructions file so the agent always has company
|
||||
// context + a hiring-plan output rule. Non-fatal on failure.
|
||||
try {
|
||||
const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId);
|
||||
await agentsApi.saveInstructionsFile(
|
||||
agent.id,
|
||||
{
|
||||
path: bundle.entryFile,
|
||||
content: composeCeoInstructions({
|
||||
companyName: input.instructions.companyName,
|
||||
companyGoal: input.instructions.companyGoal,
|
||||
growPath: input.instructions.growPath,
|
||||
growWorkflows: input.instructions.growWorkflows,
|
||||
growPainPoints: input.instructions.growPainPoints,
|
||||
growAutomate: input.instructions.growAutomate,
|
||||
q1: input.instructions.q1,
|
||||
q2: input.instructions.q2,
|
||||
q3: input.instructions.q3,
|
||||
q4: input.instructions.q4,
|
||||
}),
|
||||
},
|
||||
createdCompanyId,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("Failed to seed CEO instructions:", err);
|
||||
}
|
||||
|
||||
return { agentId: agent.id };
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the onboarding goal + project exist, create the first task assigned
|
||||
* to the lead agent, select the company, and return the target company prefix
|
||||
* for the caller to navigate to. Idempotent across the goal/project/issue it
|
||||
* creates. Requires a created company + agent.
|
||||
*/
|
||||
async function launchFirstTask(input?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}): Promise<{ companyId: string; companyPrefix: string | null } | null> {
|
||||
if (!createdCompanyId || !createdAgentId) {
|
||||
setError(
|
||||
"Onboarding state is incomplete. Please restart onboarding and try again.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let goalId = createdCompanyGoalId;
|
||||
if (!goalId) {
|
||||
const goals = await goalsApi.list(createdCompanyId);
|
||||
goalId = selectDefaultCompanyGoalId(goals);
|
||||
setCreatedCompanyGoalId(goalId);
|
||||
}
|
||||
|
||||
let projectId = createdProjectId;
|
||||
if (!projectId) {
|
||||
const projects = await projectsApi.list(createdCompanyId);
|
||||
const existingOnboardingProject = selectReusableOnboardingProject(projects);
|
||||
if (existingOnboardingProject) {
|
||||
projectId = existingOnboardingProject.id;
|
||||
} else {
|
||||
const project = await projectsApi.create(
|
||||
createdCompanyId,
|
||||
buildOnboardingProjectPayload(goalId),
|
||||
);
|
||||
projectId = project.id;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.list(createdCompanyId),
|
||||
});
|
||||
}
|
||||
setCreatedProjectId(projectId);
|
||||
}
|
||||
|
||||
if (!createdIssueRef) {
|
||||
const issue = await issuesApi.create(
|
||||
createdCompanyId,
|
||||
buildOnboardingIssuePayload({
|
||||
title: input?.title ?? DEFAULT_TASK_TITLE,
|
||||
description: input?.description ?? DEFAULT_TASK_DESCRIPTION,
|
||||
assigneeAgentId: createdAgentId,
|
||||
projectId,
|
||||
goalId,
|
||||
}),
|
||||
);
|
||||
setCreatedIssueRef(issue.identifier ?? issue.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(createdCompanyId),
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedCompanyId(createdCompanyId);
|
||||
return { companyId: createdCompanyId, companyPrefix: createdCompanyPrefix };
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to launch first task");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setCreatedCompany(company: { id: string; prefix: string | null }) {
|
||||
setCreatedCompanyId(company.id);
|
||||
setCreatedCompanyPrefix(company.prefix);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setCreatedCompanyId(null);
|
||||
setCreatedCompanyPrefix(null);
|
||||
setCreatedAgentId(null);
|
||||
setCreatedCompanyGoalId(null);
|
||||
setCreatedProjectId(null);
|
||||
setCreatedIssueRef(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setAdapterEnvResult(null);
|
||||
setAdapterEnvError(null);
|
||||
setAdapterEnvLoading(false);
|
||||
setAdapterEnvProbedKey(null);
|
||||
setForceUnsetAnthropicApiKey(false);
|
||||
setUnsetAnthropicLoading(false);
|
||||
}
|
||||
|
||||
return {
|
||||
// created entity state
|
||||
createdCompanyId,
|
||||
createdCompanyPrefix,
|
||||
createdAgentId,
|
||||
createdCompanyGoalId,
|
||||
createdProjectId,
|
||||
createdIssueRef,
|
||||
setCreatedCompany,
|
||||
setCreatedCompanyPrefix,
|
||||
|
||||
// status
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
|
||||
// adapter environment probe
|
||||
adapterEnvResult,
|
||||
adapterEnvError,
|
||||
adapterEnvLoading,
|
||||
forceUnsetAnthropicApiKey,
|
||||
unsetAnthropicLoading,
|
||||
buildAdapterConfig,
|
||||
runAdapterEnvironmentTest,
|
||||
clearAdapterEnvResult,
|
||||
unsetAnthropicApiKeyAndRetry,
|
||||
|
||||
// actions
|
||||
createCompanyAndGoal,
|
||||
hireLeadAgent,
|
||||
launchFirstTask,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export type OnboardingFlow = ReturnType<typeof useOnboardingFlow>;
|
||||
|
|
@ -993,57 +993,38 @@
|
|||
|
||||
/* Agent capsule motif (PAP-119) — "the capsule is the agent".
|
||||
Three states realized in the AgentCapsule component:
|
||||
- slot: static dashed outline (an empty agent slot)
|
||||
- slot: dashed outline gently pulsing (an empty agent slot)
|
||||
- configured: solid stroke (named/model picked, not yet live)
|
||||
- online: brand agent-gradient fill radiating outward from the center
|
||||
+ online-pulse (the only pulsing state)
|
||||
prefers-reduced-motion skips the fill + pulses and renders the final
|
||||
state. */
|
||||
/* Radial outward reveal of the (linear-gradient) fill: a centered circular
|
||||
clip grows past the half-diagonal (~71%) so it covers the whole capsule. */
|
||||
@keyframes agent-cap-fill-radial {
|
||||
0% { clip-path: circle(0% at 50% 50%); }
|
||||
100% { clip-path: circle(75% at 50% 50%); }
|
||||
- online: brand agent-gradient liquid rises to fill + green online-pulse
|
||||
prefers-reduced-motion skips the liquid rise + pulses and renders the
|
||||
final state. */
|
||||
@keyframes agent-cap-slot-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes agent-cap-rise {
|
||||
0% { height: 0; }
|
||||
100% { height: 100%; }
|
||||
}
|
||||
|
||||
@keyframes agent-cap-online-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent),
|
||||
0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent),
|
||||
0 0 0 14px color-mix(in oklab, #22c55e 7%, transparent);
|
||||
box-shadow: 0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-cap-slot {
|
||||
animation: agent-cap-slot-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-cap-liquid {
|
||||
/* Dramatic ease-in-out: the radial fill eases in, rushes, and eases out. */
|
||||
animation: agent-cap-fill-radial var(--agent-cap-reveal-duration, 1.4s)
|
||||
cubic-bezier(0.83, 0, 0.17, 1) forwards;
|
||||
}
|
||||
|
||||
/* Online carryover: the solid white outline begins fading as soon as the fill
|
||||
starts so the two overlap toward the start (outline gone by the fill's
|
||||
midpoint). Shares the reveal duration so an onboarding-scoped override slows
|
||||
both together. */
|
||||
@keyframes agent-cap-stroke-carryover {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-cap-stroke-carryover {
|
||||
animation: agent-cap-stroke-carryover var(--agent-cap-reveal-duration, 1.4s)
|
||||
linear forwards;
|
||||
animation: agent-cap-rise 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.agent-cap-online {
|
||||
|
|
@ -1054,15 +1035,11 @@
|
|||
Same breathing ring as the green pulse, recoloured to brand blue (#2563eb). */
|
||||
@keyframes agent-cap-online-pulse-blue {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent),
|
||||
0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent),
|
||||
0 0 0 14px color-mix(in oklab, #2563eb 9%, transparent);
|
||||
box-shadow: 0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
|
@ -1078,6 +1055,7 @@
|
|||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-cap-slot,
|
||||
.agent-cap-online,
|
||||
.agent-cap-online-blue {
|
||||
animation: none;
|
||||
|
|
@ -1087,11 +1065,7 @@
|
|||
}
|
||||
.agent-cap-liquid {
|
||||
animation: none;
|
||||
clip-path: none;
|
||||
}
|
||||
.agent-cap-stroke-carryover {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2350,8 +2324,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
* allow ui/src/pages/RunTranscriptUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
|
||||
* allow ui/src/pages/InviteUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
|
||||
* allow ui/src/pages/IssueChatUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
|
||||
* allow ui/src/components/onboarding/OnboardingAuthScreens.tsx — Google/GitHub official brand-mark SVG fills (third-party logo colors); brand identity values, not themeable roles
|
||||
* allow ui/src/components/onboarding/onboarding-data.ts — connector glyph colors are third-party brand hexes (GitHub/Google/Notion/Linear/…) rendered as logo-tile backgrounds; brand identity values, not themeable roles
|
||||
* allow ui/src/pages/SystemNoticeUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
|
||||
|
||||
Test-fixture hex-literal policy (Batch 4, resolving TOKEN-AUDIT.md section
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildOnboardingAdapterConfig } from "./onboarding-adapter-config";
|
||||
|
||||
const baseInput = {
|
||||
model: "",
|
||||
command: "",
|
||||
args: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
describe("buildOnboardingAdapterConfig", () => {
|
||||
it("skips permissions for claude_local", () => {
|
||||
const config = buildOnboardingAdapterConfig({
|
||||
...baseInput,
|
||||
adapterType: "claude_local",
|
||||
forceUnsetAnthropicApiKey: false,
|
||||
});
|
||||
expect(config.dangerouslySkipPermissions).toBe(true);
|
||||
});
|
||||
|
||||
it("forces ANTHROPIC_API_KEY empty for claude_local when requested", () => {
|
||||
const config = buildOnboardingAdapterConfig({
|
||||
...baseInput,
|
||||
adapterType: "claude_local",
|
||||
forceUnsetAnthropicApiKey: true,
|
||||
});
|
||||
const env = config.env as Record<string, unknown>;
|
||||
expect(env.ANTHROPIC_API_KEY).toEqual({ type: "plain", value: "" });
|
||||
});
|
||||
|
||||
it("does not force ANTHROPIC_API_KEY when the flag is off", () => {
|
||||
const config = buildOnboardingAdapterConfig({
|
||||
...baseInput,
|
||||
adapterType: "claude_local",
|
||||
forceUnsetAnthropicApiKey: false,
|
||||
});
|
||||
const env = config.env as Record<string, unknown> | undefined;
|
||||
expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
|
||||
});
|
||||
|
||||
it("only applies the ANTHROPIC_API_KEY override to claude_local", () => {
|
||||
const config = buildOnboardingAdapterConfig({
|
||||
...baseInput,
|
||||
adapterType: "codex_local",
|
||||
forceUnsetAnthropicApiKey: true,
|
||||
});
|
||||
const env = config.env as Record<string, unknown> | undefined;
|
||||
expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
// Pure adapter-config builder for onboarding, extracted verbatim from
|
||||
// OnboardingWizard.buildAdapterConfig so the new onboarding flow and the
|
||||
// useOnboardingFlow hook can build an agent adapter config from user inputs
|
||||
// without duplicating the branch logic.
|
||||
import { getUIAdapter } from "@/adapters";
|
||||
import { defaultCreateValues } from "@/components/agent-config-defaults";
|
||||
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
|
||||
export interface OnboardingAdapterConfigInput {
|
||||
adapterType: string;
|
||||
model: string;
|
||||
command: string;
|
||||
args: string;
|
||||
url: string;
|
||||
/**
|
||||
* When true and the adapter is claude_local, force ANTHROPIC_API_KEY to an
|
||||
* empty plain value so a subscription login is used instead of a stray env
|
||||
* key that overrides it.
|
||||
*/
|
||||
forceUnsetAnthropicApiKey: boolean;
|
||||
}
|
||||
|
||||
export function buildOnboardingAdapterConfig(
|
||||
input: OnboardingAdapterConfigInput,
|
||||
): Record<string, unknown> {
|
||||
const { adapterType, model, command, args, url, forceUnsetAnthropicApiKey } = input;
|
||||
const adapter = getUIAdapter(adapterType);
|
||||
const config = adapter.buildAdapterConfig({
|
||||
...defaultCreateValues,
|
||||
adapterType,
|
||||
model:
|
||||
adapterType === "gemini_local"
|
||||
? model || DEFAULT_GEMINI_LOCAL_MODEL
|
||||
: adapterType === "cursor"
|
||||
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: adapterType === "opencode_local"
|
||||
? model || DEFAULT_OPENCODE_LOCAL_MODEL
|
||||
: model,
|
||||
command,
|
||||
args,
|
||||
url,
|
||||
dangerouslySkipPermissions:
|
||||
adapterType === "claude_local" || adapterType === "opencode_local",
|
||||
dangerouslyBypassSandbox:
|
||||
adapterType === "codex_local"
|
||||
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
|
||||
: defaultCreateValues.dangerouslyBypassSandbox,
|
||||
});
|
||||
if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) {
|
||||
const env =
|
||||
typeof config.env === "object" &&
|
||||
config.env !== null &&
|
||||
!Array.isArray(config.env)
|
||||
? { ...(config.env as Record<string, unknown>) }
|
||||
: {};
|
||||
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
|
||||
config.env = env;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
// Shared onboarding constants, extracted from OnboardingWizard so the new
|
||||
// presentational onboarding flow and the useOnboardingFlow hook share one
|
||||
// source of truth for storage keys, default task copy, and error messages.
|
||||
|
||||
export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
|
||||
|
||||
export const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
|
||||
|
||||
export const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
|
||||
|
||||
- hire a founding engineer
|
||||
- write a hiring plan
|
||||
- break the roadmap into concrete tasks and start delegating work`;
|
||||
|
||||
export const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
|
||||
"Onboarding state is incomplete. Please restart onboarding and try again.";
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
// Standalone preview harness for the onboarding flows (no app shell / left nav),
|
||||
// modeled on the repo's thread-components harness.
|
||||
//
|
||||
// - `?flow=cloud` (default) renders CloudOnboardingFlow; `?flow=local` renders
|
||||
// LocalOnboardingFlow. Test both side-by-side in two tabs.
|
||||
// - The cloud flow walks the full arc (account → OTP → welcome …). The LOCAL
|
||||
// flow has no sign-in at all — it opens on its welcome screen, which carries
|
||||
// the orbiting-paperclip backdrop the auth screens would have shown.
|
||||
// - `?step=<name>` deep-links straight to one screen. Cloud: account | otp |
|
||||
// start | company | agent | task. Local: start | email | company | agent |
|
||||
// adapter | task.
|
||||
//
|
||||
// SAFETY: `previewMock` makes every wired CTA skip its backend call, so the
|
||||
// flow is clickable end-to-end with no database writes. This harness is for
|
||||
// visual review / sharing only.
|
||||
import { StrictMode, useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
import { CompanyProvider } from "./context/CompanyContext";
|
||||
import { CloudOnboardingFlow } from "./components/onboarding/CloudOnboardingFlow";
|
||||
import { LocalOnboardingFlow } from "./components/onboarding/LocalOnboardingFlow";
|
||||
import { AccountScreen, OtpScreen } from "./components/onboarding/OnboardingAuthScreens";
|
||||
import {
|
||||
AUTH_EXIT_DURATION,
|
||||
OnboardingAuthBackdrop,
|
||||
} from "./components/onboarding/OnboardingAuthBackdrop";
|
||||
import "./index.css";
|
||||
|
||||
// "exiting" holds the gap between the auth screens fading out and the flow
|
||||
// mounting, so the paperclip reaches 0% before the next screen appears.
|
||||
type Phase = "account" | "otp" | "exiting" | "flow";
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const isLocal = params.get("flow") === "local";
|
||||
const requestedStep = params.get("step");
|
||||
|
||||
// Step names valid within the selected flow (drives deep-link vs. auth phase).
|
||||
const CLOUD_STEPS = ["start", "company", "agent", "task"] as const;
|
||||
const LOCAL_STEPS = ["start", "email", "company", "agent", "adapter", "task"] as const;
|
||||
const flowSteps: readonly string[] = isLocal ? LOCAL_STEPS : CLOUD_STEPS;
|
||||
const isFlowStep = requestedStep !== null && flowSteps.includes(requestedStep);
|
||||
|
||||
const flowStart = isFlowStep ? requestedStep! : "start";
|
||||
// Which phase the harness opens on. The local flow skips sign-in entirely, so
|
||||
// it always opens on its welcome screen; cloud still walks account → OTP first.
|
||||
const initialPhase: Phase = isLocal
|
||||
? "flow"
|
||||
: isFlowStep
|
||||
? "flow"
|
||||
: requestedStep === "otp"
|
||||
? "otp"
|
||||
: "account";
|
||||
|
||||
function Preview() {
|
||||
const [phase, setPhase] = useState<Phase>(initialPhase);
|
||||
const authVisible = phase === "account" || phase === "otp";
|
||||
const exitTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(exitTimer.current), []);
|
||||
|
||||
// Finishing sign-in fades the auth card + orbiting paperclip all the way out
|
||||
// FIRST, and only then mounts the flow — so the backdrop is fully gone before
|
||||
// the next screen appears rather than the two overlapping.
|
||||
function finishAuth() {
|
||||
setPhase("exiting");
|
||||
exitTimer.current = window.setTimeout(
|
||||
() => setPhase("flow"),
|
||||
AUTH_EXIT_DURATION * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{phase === "flow" &&
|
||||
(isLocal ? (
|
||||
<LocalOnboardingFlow initialStep={flowStart as never} previewMock onClose={() => {}} />
|
||||
) : (
|
||||
<CloudOnboardingFlow initialStep={flowStart as never} previewMock onClose={() => {}} />
|
||||
))}
|
||||
<OnboardingAuthBackdrop visible={authVisible}>
|
||||
{phase === "otp" ? (
|
||||
<OtpScreen email="you@company.com" onContinue={finishAuth} />
|
||||
) : (
|
||||
<AccountScreen onContinue={() => setPhase("otp")} />
|
||||
)}
|
||||
</OnboardingAuthBackdrop>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MemoryRouter>
|
||||
<CompanyProvider>
|
||||
<TooltipProvider>
|
||||
<Preview />
|
||||
</TooltipProvider>
|
||||
</CompanyProvider>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
@ -120,15 +120,6 @@ import {
|
|||
AvatarGroupCount,
|
||||
} from "@/components/ui/avatar";
|
||||
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
|
||||
import {
|
||||
Chip,
|
||||
ChoiceCard,
|
||||
ConnectorRow,
|
||||
OnboardingCard,
|
||||
OnboardingHeading,
|
||||
Stepper,
|
||||
} from "@/components/onboarding/OnboardingPrimitives";
|
||||
import { CONNECTORS, MISSION_CHIPS } from "@/components/onboarding/onboarding-data";
|
||||
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||||
|
|
@ -262,61 +253,6 @@ function SubSection({ title, children }: { title: string; children: React.ReactN
|
|||
);
|
||||
}
|
||||
|
||||
// Onboarding flow primitives (ported prototype): interactive demos need local
|
||||
// selection state, mirroring how OnboardingFlow drives them.
|
||||
function OnboardingChipsShowcase() {
|
||||
const [active, setActive] = useState<string | null>(MISSION_CHIPS[0] ?? null);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MISSION_CHIPS.map((chip) => (
|
||||
<Chip key={chip} label={chip} active={active === chip} onClick={() => setActive(chip)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingChoiceShowcase() {
|
||||
const [choice, setChoice] = useState<"hiring" | "strategy">("hiring");
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col gap-3">
|
||||
<ChoiceCard
|
||||
icon={<Bot className="size-5" />}
|
||||
title="Create a hiring plan"
|
||||
description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
|
||||
selected={choice === "hiring"}
|
||||
onClick={() => setChoice("hiring")}
|
||||
/>
|
||||
<ChoiceCard
|
||||
icon={<ListTodo className="size-5" />}
|
||||
title="Write a team strategy doc"
|
||||
description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
|
||||
selected={choice === "strategy"}
|
||||
onClick={() => setChoice("strategy")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingConnectorShowcase() {
|
||||
const [connected, setConnected] = useState<string[]>([CONNECTORS[0]?.name ?? ""]);
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col gap-3">
|
||||
{CONNECTORS.slice(0, 2).map((c) => (
|
||||
<ConnectorRow
|
||||
key={c.name}
|
||||
connector={c}
|
||||
connected={connected.includes(c.name)}
|
||||
onToggle={() =>
|
||||
setConnected((prev) =>
|
||||
prev.includes(c.name) ? prev.filter((n) => n !== c.name) : [...prev, c.name],
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Onboarding seam (design §6 + §12.5): the TeamCard tile in its "Pick a starter
|
||||
// team" 3-col grid, with the first defaultInstall tile selected.
|
||||
function TeamCardShowcase() {
|
||||
|
|
@ -496,7 +432,6 @@ export function DesignGuide() {
|
|||
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
|
||||
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
|
||||
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip",
|
||||
"AgentCapsule", "OnboardingCard", "Stepper", "Chip", "ChoiceCard", "ConnectorRow",
|
||||
].map((name) => (
|
||||
<Badge key={name} variant="ghost" className="font-mono text-(length:--text-nano)">
|
||||
{name}
|
||||
|
|
@ -831,38 +766,6 @@ export function DesignGuide() {
|
|||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* ONBOARDING FLOW */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Onboarding Flow">
|
||||
<p className="text-sm text-muted-foreground max-w-prose">
|
||||
Primitives for the full-screen onboarding flow (
|
||||
<code className="font-mono">components/onboarding/</code>). Bespoke surfaces keep
|
||||
their prototype dimensions via verbatim size tokens (
|
||||
<code className="font-mono">--sz-560px</code> card,{" "}
|
||||
<code className="font-mono">--sz-320px</code> start tiles); type, fields, and focus
|
||||
treatment come from the shared primitives and scale.
|
||||
</p>
|
||||
<SubSection title="Card frame + heading + stepper">
|
||||
<OnboardingCard>
|
||||
<Stepper step={2} />
|
||||
<OnboardingHeading
|
||||
title="Create your first agent"
|
||||
lede="Display heading (text-4xl) with a supporting lede, inside the 560px card frame."
|
||||
/>
|
||||
</OnboardingCard>
|
||||
</SubSection>
|
||||
<SubSection title="Mission chips">
|
||||
<OnboardingChipsShowcase />
|
||||
</SubSection>
|
||||
<SubSection title="Choice cards">
|
||||
<OnboardingChoiceShowcase />
|
||||
</SubSection>
|
||||
<SubSection title="Connector rows">
|
||||
<OnboardingConnectorShowcase />
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* FORM ELEMENTS */}
|
||||
{/* ============================================================ */}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { EntityRow } from "@/components/EntityRow";
|
|||
import { FilterBar, type FilterValue } from "@/components/FilterBar";
|
||||
import { KanbanBoard } from "@/components/KanbanBoard";
|
||||
import { LiveRunWidget } from "@/components/LiveRunWidget";
|
||||
import { CloudOnboardingFlow } from "@/components/onboarding/CloudOnboardingFlow";
|
||||
import { OnboardingWizard } from "@/components/OnboardingWizard";
|
||||
import {
|
||||
buildFileTree,
|
||||
collectAllPaths,
|
||||
|
|
@ -43,6 +43,7 @@ import { SwipeToArchive } from "@/components/SwipeToArchive";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useDialog } from "@/context/DialogContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
createIssue,
|
||||
|
|
@ -323,9 +324,19 @@ function LiveRunWidgetStory({ empty = false, loading = false }: { empty?: boolea
|
|||
);
|
||||
}
|
||||
|
||||
function OnboardingStepStory({ step }: { step: "company" | "agent" }) {
|
||||
// previewMock keeps the flow backend-free for the story canvas.
|
||||
return <CloudOnboardingFlow initialStep={step} previewMock />;
|
||||
function OpenOnboardingOnMount({ initialStep }: { initialStep: 1 | 2 }) {
|
||||
const { openOnboarding } = useDialog();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.setQueryData(queryKeys.agents.adapterModels(companyId, "claude_local"), [
|
||||
{ id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-opus-4-1", label: "Claude Opus 4.1" },
|
||||
]);
|
||||
openOnboarding(initialStep === 1 ? { initialStep } : { initialStep, companyId });
|
||||
}, [initialStep, openOnboarding, queryClient]);
|
||||
|
||||
return <OnboardingWizard />;
|
||||
}
|
||||
|
||||
function PackageFileTreeDemo({ empty = false }: { empty?: boolean }) {
|
||||
|
|
@ -694,14 +705,14 @@ export const LiveRunWidgetEmpty: Story = {
|
|||
render: () => <LiveRunWidgetStory empty />,
|
||||
};
|
||||
|
||||
export const OnboardingFlowCompanyStep: Story = {
|
||||
name: "Onboarding Flow / Company Step",
|
||||
render: () => <OnboardingStepStory step="company" />,
|
||||
export const OnboardingWizardCompanyStep: Story = {
|
||||
name: "OnboardingWizard / Company Step",
|
||||
render: () => <OpenOnboardingOnMount initialStep={1} />,
|
||||
};
|
||||
|
||||
export const OnboardingFlowAgentStep: Story = {
|
||||
name: "Onboarding Flow / Agent Step",
|
||||
render: () => <OnboardingStepStory step="agent" />,
|
||||
export const OnboardingWizardAgentStep: Story = {
|
||||
name: "OnboardingWizard / Agent Step",
|
||||
render: () => <OpenOnboardingOnMount initialStep={2} />,
|
||||
};
|
||||
|
||||
export const PackageFileTreePopulated: Story = {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,6 @@ export default defineConfig(({ mode }) => ({
|
|||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
minify: "esbuild",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, "index.html"),
|
||||
"onboarding-preview": path.resolve(__dirname, "onboarding-preview.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
esbuild:
|
||||
mode === "production"
|
||||
|
|
@ -32,9 +26,7 @@ export default defineConfig(({ mode }) => ({
|
|||
},
|
||||
},
|
||||
server: {
|
||||
// Harness-managed preview servers assign a port via the PORT env var;
|
||||
// default stays 5173 for plain `vite` runs.
|
||||
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
||||
port: 5173,
|
||||
watch: createUiDevWatchOptions(process.cwd()),
|
||||
proxy: apiProxy,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue