diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts
index f1dbd0f5..f5f32be8 100644
--- a/tests/e2e/onboarding.spec.ts
+++ b/tests/e2e/onboarding.spec.ts
@@ -22,14 +22,11 @@ const TASK_TITLE = "E2E test task";
test.describe("Onboarding wizard", () => {
test("completes full wizard flow", async ({ page }) => {
- // Navigate to root — should auto-open onboarding when no companies exist
await page.goto("/");
- // If the wizard didn't auto-open (company already exists), click the button
const wizardHeading = page.locator("h3", { hasText: "Name your company" });
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
- // Wait for either the wizard or the start page
await expect(
wizardHeading.or(newCompanyBtn)
).toBeVisible({ timeout: 15_000 });
@@ -38,40 +35,28 @@ test.describe("Onboarding wizard", () => {
await newCompanyBtn.click();
}
- // -----------------------------------------------------------
- // Step 1: Name your company
- // -----------------------------------------------------------
await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
- await expect(page.locator("text=Step 1 of 4")).toBeVisible();
const companyNameInput = page.locator('input[placeholder="Acme Corp"]');
await companyNameInput.fill(COMPANY_NAME);
- // Click Next
const nextButton = page.getByRole("button", { name: "Next" });
await nextButton.click();
- // -----------------------------------------------------------
- // Step 2: Create your first agent
- // -----------------------------------------------------------
await expect(
page.locator("h3", { hasText: "Create your first agent" })
).toBeVisible({ timeout: 10_000 });
- await expect(page.locator("text=Step 2 of 4")).toBeVisible();
- // Agent name should default to "CEO"
const agentNameInput = page.locator('input[placeholder="CEO"]');
await expect(agentNameInput).toHaveValue(AGENT_NAME);
- // Claude Code adapter should be selected by default
await expect(
page.locator("button", { hasText: "Claude Code" }).locator("..")
).toBeVisible();
- // Select the "Process" adapter to avoid needing a real CLI tool installed
- await page.locator("button", { hasText: "Process" }).click();
+ await page.getByRole("button", { name: "More Agent Adapter Types" }).click();
+ await page.getByRole("button", { name: "Process" }).click();
- // Fill in process adapter fields
const commandInput = page.locator('input[placeholder="e.g. node, python"]');
await commandInput.fill("echo");
const argsInput = page.locator(
@@ -79,52 +64,34 @@ test.describe("Onboarding wizard", () => {
);
await argsInput.fill("hello");
- // Click Next (process adapter skips environment test)
await page.getByRole("button", { name: "Next" }).click();
- // -----------------------------------------------------------
- // Step 3: Give it something to do
- // -----------------------------------------------------------
await expect(
page.locator("h3", { hasText: "Give it something to do" })
).toBeVisible({ timeout: 10_000 });
- await expect(page.locator("text=Step 3 of 4")).toBeVisible();
- // Clear default title and set our test title
const taskTitleInput = page.locator(
'input[placeholder="e.g. Research competitor pricing"]'
);
await taskTitleInput.clear();
await taskTitleInput.fill(TASK_TITLE);
- // Click Next
await page.getByRole("button", { name: "Next" }).click();
- // -----------------------------------------------------------
- // Step 4: Ready to launch
- // -----------------------------------------------------------
await expect(
page.locator("h3", { hasText: "Ready to launch" })
).toBeVisible({ timeout: 10_000 });
- await expect(page.locator("text=Step 4 of 4")).toBeVisible();
- // Verify summary displays our created entities
await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible();
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
- // Click "Open Issue"
- await page.getByRole("button", { name: "Open Issue" }).click();
+ await page.getByRole("button", { name: "Create & Open Issue" }).click();
- // Should navigate to the issue page
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
- // -----------------------------------------------------------
- // Verify via API that entities were created
- // -----------------------------------------------------------
const baseUrl = page.url().split("/").slice(0, 3).join("/");
- // List companies and find ours
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
@@ -133,7 +100,6 @@ test.describe("Onboarding wizard", () => {
);
expect(company).toBeTruthy();
- // List agents for our company
const agentsRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/agents`
);
@@ -146,7 +112,6 @@ test.describe("Onboarding wizard", () => {
expect(ceoAgent.role).toBe("ceo");
expect(ceoAgent.adapterType).toBe("process");
- // List issues for our company
const issuesRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/issues`
);
@@ -159,7 +124,6 @@ test.describe("Onboarding wizard", () => {
expect(task.assigneeAgentId).toBe(ceoAgent.id);
if (!SKIP_LLM) {
- // LLM-dependent: wait for the heartbeat to transition the issue
await expect(async () => {
const res = await page.request.get(
`${baseUrl}/api/issues/${task.id}`
diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts
index 5ae1b677..fd7e8b59 100644
--- a/tests/e2e/playwright.config.ts
+++ b/tests/e2e/playwright.config.ts
@@ -23,7 +23,7 @@ export default defineConfig({
// The webServer directive starts `paperclipai run` before tests.
// Expects `pnpm paperclipai` to be runnable from repo root.
webServer: {
- command: `pnpm paperclipai run --yes`,
+ command: `pnpm paperclipai run`,
url: `${BASE_URL}/api/health`,
reuseExistingServer: !!process.env.CI,
timeout: 120_000,
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index ff1daecf..05aa5381 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,4 +1,3 @@
-import { useEffect, useRef } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -40,6 +39,7 @@ import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
+import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
@@ -175,24 +175,13 @@ function LegacySettingsRedirect() {
}
function OnboardingRoutePage() {
- const { companies, loading } = useCompany();
- const { onboardingOpen, openOnboarding } = useDialog();
+ const { companies } = useCompany();
+ const { openOnboarding } = useDialog();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
- const opened = useRef(false);
const matchedCompany = companyPrefix
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
: null;
- useEffect(() => {
- if (loading || opened.current || onboardingOpen) return;
- opened.current = true;
- if (matchedCompany) {
- openOnboarding({ initialStep: 2, companyId: matchedCompany.id });
- return;
- }
- openOnboarding();
- }, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]);
-
const title = matchedCompany
? `Add another agent to ${matchedCompany.name}`
: companies.length > 0
@@ -227,19 +216,22 @@ function OnboardingRoutePage() {
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
- const { onboardingOpen } = useDialog();
+ const location = useLocation();
if (loading) {
return
Loading...
;
}
- // Keep the first-run onboarding mounted until it completes.
- if (onboardingOpen) {
- return ;
- }
-
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
+ if (
+ shouldRedirectCompanylessRouteToOnboarding({
+ pathname: location.pathname,
+ hasCompanies: false,
+ })
+ ) {
+ return ;
+ }
return ;
}
@@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() {
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
+ if (
+ shouldRedirectCompanylessRouteToOnboarding({
+ pathname: location.pathname,
+ hasCompanies: false,
+ })
+ ) {
+ return ;
+ }
return ;
}
@@ -267,16 +267,8 @@ function UnprefixedBoardRedirect() {
);
}
-function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) {
+function NoCompaniesStartPage() {
const { openOnboarding } = useDialog();
- const opened = useRef(false);
-
- useEffect(() => {
- if (!autoOpen) return;
- if (opened.current) return;
- opened.current = true;
- openOnboarding();
- }, [autoOpen, openOnboarding]);
return (
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index 88e16d09..dbce861e 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
-import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
+import { useLocation, useNavigate, useParams } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { companiesApi } from "../api/companies";
@@ -30,6 +30,7 @@ import {
} 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 { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
@@ -75,12 +76,29 @@ After that, hire yourself a Founding Engineer agent and then plan the roadmap an
export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
- const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
+ const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const location = useLocation();
+ const { companyPrefix } = useParams<{ companyPrefix?: string }>();
+ const [routeDismissed, setRouteDismissed] = useState(false);
- const initialStep = onboardingOptions.initialStep ?? 1;
- const existingCompanyId = onboardingOptions.companyId;
+ const routeOnboardingOptions =
+ companyPrefix && companiesLoading
+ ? null
+ : resolveRouteOnboardingOptions({
+ pathname: location.pathname,
+ companyPrefix,
+ companies,
+ });
+ const effectiveOnboardingOpen =
+ onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
+ const effectiveOnboardingOptions = onboardingOpen
+ ? onboardingOptions
+ : routeOnboardingOptions ?? {};
+
+ const initialStep = effectiveOnboardingOptions.initialStep ?? 1;
+ const existingCompanyId = effectiveOnboardingOptions.companyId;
const [step, setStep] = useState(initialStep);
const [loading, setLoading] = useState(false);
@@ -134,27 +152,31 @@ export function OnboardingWizard() {
const [createdAgentId, setCreatedAgentId] = useState(null);
const [createdIssueRef, setCreatedIssueRef] = useState(null);
+ useEffect(() => {
+ setRouteDismissed(false);
+ }, [location.pathname]);
+
// Sync step and company when onboarding opens with options.
// Keep this independent from company-list refreshes so Step 1 completion
// doesn't get reset after creating a company.
useEffect(() => {
- if (!onboardingOpen) return;
- const cId = onboardingOptions.companyId ?? null;
- setStep(onboardingOptions.initialStep ?? 1);
+ if (!effectiveOnboardingOpen) return;
+ const cId = effectiveOnboardingOptions.companyId ?? null;
+ setStep(effectiveOnboardingOptions.initialStep ?? 1);
setCreatedCompanyId(cId);
setCreatedCompanyPrefix(null);
}, [
- onboardingOpen,
- onboardingOptions.companyId,
- onboardingOptions.initialStep
+ effectiveOnboardingOpen,
+ effectiveOnboardingOptions.companyId,
+ effectiveOnboardingOptions.initialStep
]);
// Backfill issue prefix for an existing company once companies are loaded.
useEffect(() => {
- if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
+ if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
const company = companies.find((c) => c.id === createdCompanyId);
if (company) setCreatedCompanyPrefix(company.issuePrefix);
- }, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
+ }, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
// Resize textarea when step 3 is shown or description changes
useEffect(() => {
@@ -171,7 +193,7 @@ export function OnboardingWizard() {
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
- enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
+ enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
});
const isLocalAdapter =
adapterType === "claude_local" ||
@@ -546,13 +568,16 @@ export function OnboardingWizard() {
}
}
- if (!onboardingOpen) return null;
+ if (!effectiveOnboardingOpen) return null;
return (