Onboarding: count the whole walk on a cloud tenant (#12706)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A cloud customer meets it as one walk that crosses two applications:
Cloud names the organization, then hands off to the tenant for the
agent, the model and the review
> - The tenant's progress strip counted only its own three steps, so the
count restarted at the crossing — four dots on the naming screen, then
three beginning again at one
> - The strip that spans the whole walk already existed and simply was
not chosen on that path, which made the walk read as two products rather
than one
> - This pull request picks the four-step strip when the run came from
Cloud, so the count runs 1, 2, 3, 4 straight through
> - The benefit is that the hand-off stops announcing itself

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The onboarding progress strip on a cloud tenant. It restarted its count
at the hand-off from Cloud, so a customer went from "1 of 4" to "1 of 3"
mid-walk.

**Subsystem affected**
Tenant onboarding wizard — `ui/src/components/OnboardingWizard.tsx`.

**Current behavior**
`showsAgentArcStepper` is `isAgentArcStep && entryStep >= 3`. Every run
entering on the agent step gets the three-step strip, including a cloud
tenant whose organization was named one screen earlier in Cloud.

**Proposed behavior**
A cloud tenant gets the four-step strip, positioned 2, 3 and 4. A
self-hosted run entering on the same step keeps three.

**Reason and benefit**
The count describes the walk the customer is on rather than the half of
it this application happens to render.

**Breaking changes**
None. No API or schema change; the self-hosted path is unchanged.

## What Changed

- Adds `enteredFromCloud`, read from `enableManagedSandboxOnly` — the
cloud-tenant shape the connect step already resolves its login
environment through.
- Excludes that case from `showsAgentArcStepper`, so it falls to the
existing four-step strip. `ONBOARDING_STEP_LABELS` and
`onboardingStepPositionFor` already produce 2, 3 and 4; neither needed
changing.
- Widens the experimental-settings query from step 4 to steps 3–5, so
the strip can read it on every step of the arc.
- Adds tests for both strip lengths.

## Verification

- `pnpm vitest run src/components src/adapters storybook` in `ui/` —
2309 tests pass.
- `pnpm typecheck` in `ui/` — clean.
- Storybook, `Onboarding/Agent arc`: its fixture is cloud-shaped, so the
three steps now announce "Step 2 of 4", "Step 3 of 4" and "Step 4 of 4"
with four dots.
- The two new tests fail without the change — the cloud case reports
`expected 'Step 1 of 3' to be 'Step 2 of 4'`.

## Risks

- **The two runs entering on the agent step are genuinely different, and
only one signal separates them.** A cloud tenant walked a naming screen
in Cloud; a self-hosted company with no agents walked nothing before
this. Getting it backwards would either restart the count or credit a
step nobody walked. Both directions are now tested, which they were not
before — the suite covered `agentArcStepFor` but nothing asserted which
strip renders.
- `enableManagedSandboxOnly` is being read as a proxy for "came from
Cloud". It is the same signal the connect step already trusts for its
environment, so this does not introduce a new dependency, but it is
inference rather than a fact passed at the hand-off. If the step count
ever needs to vary, Cloud should seed the position explicitly instead.
- The widened query means the value can be unresolved on first paint,
and the strip briefly renders three dots before settling to four. The
query key is shared and usually already cached, so this is rare rather
than routine — worth watching on staging, and the fix if it shows is to
hold the strip until the value resolves.
- Cosmetic only. Nothing here changes which steps run, what they
collect, or where the walk goes.

## Model Used

Claude Opus 5 (`claude-opus-5`), via Claude Code with extended thinking,
tool use, and browser-driven verification of the rendered strip.

## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tonio 2026-09-01 18:06:38 -07:00 committed by GitHub
parent 8f9f850c20
commit 889f5853f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 66 additions and 2 deletions

View File

@ -229,6 +229,53 @@ describe("OnboardingWizard — which step it lands on", () => {
vi.clearAllMocks();
});
/**
* The progress strip counts the walk the customer is actually on, and the two
* runs that enter on the agent step are on different walks.
*
* Both have a company already, so `entryStep` cannot tell them apart. What
* does is `enableManagedSandboxOnly` the cloud-tenant shape. A cloud tenant
* was asked for its organization's name by Cloud, one screen earlier, so its
* walk is four and this is the second. A self-hosted company that simply has
* no agents yet was asked nothing before this, so its walk is three.
*/
describe("progress strip length", () => {
function announcedCount(): string | null {
return (
[...document.querySelectorAll(".sr-only")]
.map((element) => element.textContent?.trim() ?? "")
.find((text) => /^Step \d+ of \d+$/.test(text)) ?? null
);
}
it("counts four on a cloud tenant, continuing the count Cloud started", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: true,
});
routerState.pathname = "/PC1/onboarding";
await render();
await settle();
expect(currentStep()).toBe("agent");
expect(announcedCount()).toBe("Step 2 of 4");
});
it("counts three on a self-hosted company that has no agents yet", async () => {
// Nothing was asked before this step here, so a fourth segment would be
// one the run can never fill — and it would credit the customer with a
// step they never walked.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: false,
});
routerState.pathname = "/PC1/onboarding";
await render();
await settle();
expect(currentStep()).toBe("agent");
expect(announcedCount()).toBe("Step 1 of 3");
});
});
it("opens a company that already has its mission on the agent step", async () => {
// The point of the change: Cloud collected the mission at signup and the
// seed wrote it as a company-level goal, so asking for it again asks a

View File

@ -890,10 +890,13 @@ function OnboardingWizardInner({
queryFn: () => instanceSettingsApi.get(),
enabled: effectiveOnboardingOpen && step === 4,
});
// Wanted across the whole arc, not just the connect step. The progress strip
// reads it too — see `enteredFromCloud` — and a value fetched only on step 4
// would let the strip change length as the customer walked through it.
const { data: experimentalSettingsForLogin } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: effectiveOnboardingOpen && step === 4,
enabled: effectiveOnboardingOpen && step >= 3 && step <= 5,
});
const resolvedLoginEnvironmentId = useMemo(() => {
try {
@ -2004,7 +2007,21 @@ function OnboardingWizardInner({
}
const isAgentArcStep = agentArcStepFor(step) !== null;
const showsAgentArcStepper = isAgentArcStep && entryStep >= 3;
/**
* True when the organization was named in Cloud rather than here.
*
* `enableManagedSandboxOnly` is the cloud-tenant shape the connect step
* already resolves its login environment through it. A tenant wearing it did
* not ask for the organization's name, because Cloud did, so the walk the
* customer is on is four steps and this is the second.
*
* A self-hosted run that enters at the agent step is a different case with
* the same `entryStep`: an existing company that has no agents yet. There was
* no naming screen before it, so its walk really is three, and it keeps the
* shorter strip.
*/
const enteredFromCloud = experimentalSettingsForLogin?.enableManagedSandboxOnly === true;
const showsAgentArcStepper = isAgentArcStep && entryStep >= 3 && !enteredFromCloud;
const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId);
const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null);