mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - New customers arrive through an onboarding arc that spans Paperclip Cloud and the tenant app > - Cloud's naming screen stopped asking for the company mission, but the tenant wizard still decided its first step by asking whether the company had one > - Every Cloud-created company therefore looked mission-less on arrival, so every walk detoured through a "Define your mission" screen the design had already removed > - This pull request removes that step from the arc, and makes step 1 create the company itself > - The benefit is a shorter arc that matches the design, and a three-step progress strip that now counts the steps that exist ## Linked Issues or Issue Description No public issue exists. The problem was found by walking staging end to end. **What happened:** A new customer signs in, names their organization, and waits for it to build. The tenant wizard then asks "Define your mission" before it asks for the first agent. Cloud no longer collects a mission, so this screen appears for every new customer. **Expected behavior:** The wizard asks for the first agent, the model, and a review. The progress strip counts three steps. **Actual behavior:** The wizard asks for the mission first. The progress strip counts five segments, because the run does not enter on the agent arc. **Additional context:** Three merged pull requests built the mission-based step choice this change removes: #11352, #11416 and #11429. The mission is now collected later, inside the tenant app, so onboarding does not ask for it at all. ## What Changed - `onboardingStepForCompany` always returns the agent step. The `companyHasMission` parameter is removed, because it cannot change the answer. - `resolveRouteOnboardingOptions` no longer accepts `companyHasMission`. - The dashboard no longer waits for the goal lookup before it opens the wizard. That wait only chose a step, and the step is now fixed. - Step 1 creates the company in a new `handleCreateCompany`. Company creation used to sit at the end of `handleConfirmMission`. - No company goal is written during onboarding. - The three-step strip now shows on the agent, model and review steps, because every Cloud-first run enters on the arc. - The full-length bar drops its second segment. No run can fill it. - The grow path keeps its step 2 questionnaire. Only the create path skips ahead. - Back from the agent step goes to the screen the run came from. - Four end-to-end specs no longer drive the wizard through the mission step. ## Verification Run the tenant test suite: ``` cd ui && npx vitest run ``` - 4356 tests pass. 471 files pass. - `npx tsc --noEmit` reports no errors. - Fault injection: forcing `skipsMissionStep` to `true` fails the grow questionnaire test. Removing the Back rule fails the Back test. Both tests fail on the exact defect they guard. - The three-step strip is asserted by an existing test. It checks `Step 1 of 3` and `aria-label="Create your first agent"`. Manual check on staging after the paired Cloud change: 1. Open a new incognito window. 2. Sign in with a new account. 3. Name the organization. 4. Confirm the wizard shows "Create your first agent" and "Step 1 of 3". ## Risks - **Behavioral change.** Onboarding no longer writes a company goal. An agent hired during onboarding starts without a seeded mission. This is intended. The mission moves to the tenant app. - **Dead code.** `ONBOARDING_MISSION_STEP` and the mission screen stay in the codebase, but nothing in the app opens them. They wait for the surface that collects the mission later. - **Grow path.** The grow path is unchanged, but it shares step 2 with the removed screen. New tests cover it. - **Superseded work.** #11352, #11416 and #11429 tuned the mission-based step choice. This change removes the branch they tuned. ## Model Used Claude Opus 5 (`claude-opus-5`), extended thinking, with tool use and code execution through Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
210 lines
6.7 KiB
TypeScript
210 lines
6.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
ONBOARDING_AGENT_STEP,
|
|
} from "./lib/onboarding-route";
|
|
|
|
/**
|
|
* The third way into onboarding for a company that already exists: the "Add
|
|
* Agent" card on `/{prefix}/onboarding`, shown once the wizard is dismissed.
|
|
*
|
|
* The route resolver and the dashboard both choose the step from whether the
|
|
* company already has its mission. This button hardcoded the mission step, so
|
|
* the one entry point whose own copy says "add another agent" was the one that
|
|
* stopped to ask for the mission again.
|
|
*/
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
// jsdom's CSS parser rejects the custom-property marker rule stitches inserts
|
|
// (`--sxs{--sxs:N}`), pulled into <App>'s eager import graph transitively via
|
|
// @codesandbox/sandpack-react. Substitute a benign, valid rule on parse failure
|
|
// so stitches' index bookkeeping stays intact and the module graph evaluates.
|
|
vi.hoisted(() => {
|
|
const sheetProto = window.CSSStyleSheet.prototype as unknown as {
|
|
insertRule: (rule: string, index?: number) => number;
|
|
__papLauncherPatched?: boolean;
|
|
};
|
|
if (!sheetProto.__papLauncherPatched) {
|
|
const original = sheetProto.insertRule;
|
|
sheetProto.insertRule = function patched(this: CSSStyleSheet, rule: string, index?: number) {
|
|
try {
|
|
return original.call(this, rule, index);
|
|
} catch {
|
|
try {
|
|
return original.call(this, ".pap-launcher-noop{}", index);
|
|
} catch {
|
|
return this.cssRules?.length ?? 0;
|
|
}
|
|
}
|
|
};
|
|
sheetProto.__papLauncherPatched = true;
|
|
}
|
|
});
|
|
|
|
const routeState = vi.hoisted(() => ({ companyPrefix: "PC1" as string | undefined }));
|
|
const dialogState = vi.hoisted(() => ({
|
|
onboardingOpen: false,
|
|
onboardingRouteDismissed: true,
|
|
openOnboarding: vi.fn(),
|
|
}));
|
|
const mockGoalsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
|
|
|
vi.mock("@/lib/router", async () => {
|
|
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
|
return {
|
|
...actual,
|
|
useParams: () => ({ companyPrefix: routeState.companyPrefix }),
|
|
useActiveCompanyPrefix: () => routeState.companyPrefix ?? null,
|
|
};
|
|
});
|
|
|
|
vi.mock("./context/CompanyContext", () => ({
|
|
useCompany: () => ({
|
|
companies: [{ id: "company-1", name: "Acme", issuePrefix: "PC1" }],
|
|
selectedCompanyId: "company-1",
|
|
loading: false,
|
|
error: null,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./context/DialogContext", () => ({
|
|
useDialogActions: () => ({ openOnboarding: dialogState.openOnboarding }),
|
|
useDialogState: () => ({
|
|
onboardingOpen: dialogState.onboardingOpen,
|
|
onboardingRouteDismissed: dialogState.onboardingRouteDismissed,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./api/goals", () => ({ goalsApi: mockGoalsApi }));
|
|
|
|
const { OnboardingRoutePage } = await import("./App");
|
|
|
|
const COMPANY_GOAL = {
|
|
id: "goal-1",
|
|
companyId: "company-1",
|
|
title: "Scale the marketplace",
|
|
description: null,
|
|
level: "company",
|
|
status: "active",
|
|
parentId: null,
|
|
ownerAgentId: null,
|
|
createdAt: new Date("2026-03-02T00:00:00Z"),
|
|
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
|
};
|
|
|
|
describe("the onboarding launcher's Add Agent button", () => {
|
|
let container: HTMLDivElement;
|
|
let queryClient: QueryClient;
|
|
let root: Root | null = null;
|
|
|
|
async function render() {
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<OnboardingRoutePage />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
}
|
|
|
|
async function settle(ticks = 12) {
|
|
for (let i = 0; i < ticks; i++) {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
}
|
|
|
|
function addAgentButton(): HTMLButtonElement {
|
|
const button = [...container.querySelectorAll("button")].find((b) =>
|
|
b.textContent?.includes("Add Agent"),
|
|
);
|
|
// Asserted rather than optional-chained: a lookup that silently matches
|
|
// nothing turns the click below into a no-op and the test into decoration.
|
|
expect(button).toBeDefined();
|
|
return button!;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
routeState.companyPrefix = "PC1";
|
|
dialogState.onboardingOpen = false;
|
|
dialogState.onboardingRouteDismissed = true;
|
|
mockGoalsApi.list.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root?.unmount());
|
|
root = null;
|
|
queryClient.clear();
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("opens on the agent step for a company that already has its mission", async () => {
|
|
mockGoalsApi.list.mockResolvedValue([COMPANY_GOAL]);
|
|
await render();
|
|
await settle();
|
|
|
|
await act(async () => {
|
|
addAgentButton().dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(dialogState.openOnboarding).toHaveBeenCalledWith({
|
|
initialStep: ONBOARDING_AGENT_STEP,
|
|
companyId: "company-1",
|
|
});
|
|
});
|
|
|
|
it("goes to the agent step whatever the goals lookup says", async () => {
|
|
// Two tests lived here — company with no mission, and a lookup that failed —
|
|
// and both sent "Add Agent" to the mission step first. Onboarding no longer
|
|
// asks for the mission, so neither goal state can divert a button whose
|
|
// whole purpose is adding an agent.
|
|
for (const goals of [
|
|
() => mockGoalsApi.list.mockResolvedValue([]),
|
|
() => mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable")),
|
|
]) {
|
|
dialogState.openOnboarding.mockClear();
|
|
goals();
|
|
await render();
|
|
await settle();
|
|
|
|
await act(async () => {
|
|
addAgentButton().dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(dialogState.openOnboarding).toHaveBeenCalledWith({
|
|
initialStep: ONBOARDING_AGENT_STEP,
|
|
companyId: "company-1",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("opens with no company when the prefix matches none", async () => {
|
|
routeState.companyPrefix = "NOPE";
|
|
await render();
|
|
await settle();
|
|
|
|
const start = [...container.querySelectorAll("button")].find((b) =>
|
|
b.textContent?.includes("Start Onboarding"),
|
|
);
|
|
expect(start).toBeDefined();
|
|
await act(async () => {
|
|
start!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(dialogState.openOnboarding).toHaveBeenCalledWith();
|
|
});
|
|
});
|