fix(onboarding): advance past the organization step when the app adopts the created company

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.
This commit is contained in:
Devin Foley committed 2026-09-01 18:49:10 -07:00
1 parent dbf052577d
commit 65ec059bde
3 files changed
+81 -2

No files matched your search

+13
View File
@@ -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.
@@ -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 () => {
+24 -2
View File
@@ -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