From 65ec059bde30d98c92165b24a30a540800dd1f6f Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 1 Sep 2026 18:49:10 -0700 Subject: [PATCH] fix(onboarding): advance past the organization step when the app adopts the created company MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the OnboardingWizard fix from #12667 to the 2026.831.0 stable line. On the first onboarding screen ("What is the name of your organization?"), clicking Continue created the organization but the guard `stillTheSameCompany(null)` treated the surrounding app adopting the just-created company (via the company-list refresh) as a takeover and returned before advancing. The wizard stayed on the name step while the organization was created — and every retry created another duplicate. Recognize the adopt-the-POST-result transition as the same successful company and advance to the first-agent step; a genuine switch to a different organization still stops with a clear error. Includes the regression test, which fails on the unpatched wizard. --- releases/v2026.831.1.md | 13 ++++++ .../components/OnboardingWizard.step.test.tsx | 44 +++++++++++++++++++ ui/src/components/OnboardingWizard.tsx | 26 ++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 releases/v2026.831.1.md diff --git a/releases/v2026.831.1.md b/releases/v2026.831.1.md new file mode 100644 index 00000000000..f37b121e158 --- /dev/null +++ b/releases/v2026.831.1.md @@ -0,0 +1,13 @@ +# Paperclip v2026.831.1 + +> Released: 2026-09-02 + +A patch on top of [2026.831.0](https://github.com/paperclipai/paperclip/releases/tag/v2026.831.0) that fixes first-run onboarding. It carries a single targeted change; everything else in 2026.831.0 is unchanged. + +## Fixes + +- **Onboarding no longer gets stuck on the organization step.** On the first onboarding screen ("What is the name of your organization?"), clicking **Continue** created the organization on the server but could leave the wizard sitting on the same step — so the page appeared to just refresh, and repeated clicks silently created duplicate organizations. The company-created list refresh can make the surrounding app adopt the just-created organization before the create request's continuation runs; the wizard now recognizes that as the same successful transition and advances to the first-agent step, while a genuine switch to a different organization still stops with a clear message. ([#12667](https://github.com/paperclipai/paperclip/pull/12667)) + +## Upgrade Guide + +- No migrations, no configuration changes, no API changes. Upgrade in place. diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index fb4cb0170c3..9d732922d16 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -728,6 +728,50 @@ describe("OnboardingWizard — which step it lands on", () => { // here rather than read as a pass. expect(currentStep()).toBe("agent"); expect(companyState.setSelectedCompanyId).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + "Organization created, but onboarding switched to another organization.", + ); + }); + + it("finishes advancing when the returned company was already adopted", async () => { + // The company-created live update can make the surrounding app adopt the + // returned company before the POST continuation runs. That is not a + // different-company takeover: both signals name the same company, so + // dropping the continuation leaves the customer on the name step even + // though the organization now exists. + let resolveCreate: (company: { id: string; issuePrefix: string }) => void = () => {}; + mockCompaniesApi.create.mockReturnValue( + new Promise<{ id: string; issuePrefix: string }>((resolve) => { + resolveCreate = resolve; + }), + ); + routerState.pathname = "/onboarding"; + await render(); + await settle(); + + const nameInput = document.body.querySelector("input")! as HTMLInputElement; + setControlledValue(nameInput, "Initech"); + await settle(); + const next = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Continue", + )!; + await act(async () => { + next.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // Model the surrounding app adopting exactly the company that the pending + // request is about, without choosing a new step on the wizard's behalf. + dialogState.onboardingOpen = true; + dialogState.onboardingOptions = { companyId: "company-created" }; + await rerender(); + await settle(); + + await act(async () => resolveCreate({ id: "company-created", issuePrefix: "INI" })); + await settle(); + + expect(currentStep()).toBe("agent"); + expect(companyState.setSelectedCompanyId).toHaveBeenCalledWith("company-created"); + expect(mockCompaniesApi.create).toHaveBeenCalledTimes(1); }); it("applies the step again when the wizard is re-opened", async () => { diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 138f100c235..53dd7eced08 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -903,6 +903,26 @@ function OnboardingWizardInner({ return createdCompanyIdRef.current === companyIdAtStart; } + /** + * Whether a just-created company can still be committed to this wizard. + * + * Company-list refreshes can make the surrounding app adopt the POST result + * before the continuation runs. That is the same successful transition, not + * a takeover. A different id still means navigation moved the wizard to a + * different organization while the request was in flight. + */ + function canCommitCreatedCompany( + companyIdAtStart: string | null, + returnedCompanyId: string, + ) { + const companyIdNow = createdCompanyIdRef.current; + if (companyIdNow === companyIdAtStart || companyIdNow === returnedCompanyId) { + return true; + } + setError("Organization created, but onboarding switched to another organization."); + return false; + } + async function handleLaunchToDashboard() { if (!createdCompanyId || !createdAgentId) { setError(INCOMPLETE_ONBOARDING_STATE_MESSAGE); @@ -1173,6 +1193,7 @@ function OnboardingWizardInner({ } setLoading(true); setError(null); + const companyIdAtStart = createdCompanyIdRef.current; try { const company = await companiesApi.create({ name: companyName.trim() }); queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); @@ -1181,7 +1202,7 @@ function OnboardingWizardInner({ // a company while the request was open has taken over the wizard, and // adopting the company just created would fight it — and would leave the // customer on a company they never navigated to. - if (!stillTheSameCompany(null)) return; + if (!canCommitCreatedCompany(companyIdAtStart, company.id)) return; setCreatedCompanyId(company.id); // Keep the mirror current here rather than waiting for the next render. // The goal write below asks `stillTheSameCompany(company.id)`, and a ref @@ -1237,6 +1258,7 @@ function OnboardingWizardInner({ creatingCompanyRef.current = true; setLoading(true); setError(null); + const companyIdAtStart = createdCompanyIdRef.current; try { const company = await companiesApi.create({ name: companyName.trim() }); queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); @@ -1245,7 +1267,7 @@ function OnboardingWizardInner({ // taken over the wizard, and adopting the company just created would // fight it — and would leave the customer on a company they never // navigated to. - if (!stillTheSameCompany(null)) return; + if (!canCommitCreatedCompany(companyIdAtStart, company.id)) return; setCreatedCompanyId(company.id); // Keep the mirror current rather than waiting for the next render, for // the same reason the mission path does: anything downstream that asks