Files
PaperClipAI/ui/src/hooks/useOnboardingFlow.ts
T
TonioandClaude Opus 5 11e56654f8 feat(ui): port onboarding flow from prototype; add cloud + local variants (#10786)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - First-run onboarding is the subsystem that turns a brand-new install
into a working company: it creates the company, its goal, a lead agent,
and that agent's first task
> - The existing `OnboardingWizard` carried all of that wiring
correctly, but its UI had drifted from the current design direction, and
a separate design prototype (`paperclip-onboard`) existed as a
standalone visual mock with no backend
> - Porting the prototype's *logic* would have thrown away working,
well-tested backend orchestration; leaving the two apart meant the
design never shipped
> - Separately, cloud and local (self-hosted) installs need meaningfully
different first runs — local has no sign-in and must let the user pick a
locally-installed CLI adapter — so a single linear wizard could not
serve both
> - This pull request rebuilds the presentational layer from the
prototype on top of the existing backend orchestration, and splits it
into two thin flow containers over a shared core
> - The benefit is that the shipped onboarding matches the intended
design, cloud and local can diverge without duplicating logic, and each
can later ship to a different app version while sharing one set of step
components

## Linked Issues or Issue Description

No existing issue — describing inline (feature request).

**What problem does this solve?**
Onboarding is the first thing a new user sees, and the shipped wizard
had drifted from the current design. In parallel, cloud and local
installs need different first-run paths: local has no hosted sign-in,
and its agent runs on a CLI adapter installed on the user's machine,
which the cloud path never has to ask about. There was no way to express
that difference without either forking the whole wizard or bolting
conditionals onto a single linear flow.

**Proposed solution**
Extract the onboarding step views and shell into a shared core, then
compose two thin flow containers (cloud and local) over it. Keep all
backend orchestration in the existing `useOnboardingFlow` hook so no
working logic is rewritten.

**Alternatives considered**
- *Single flow with a `variant` prop* — most DRY, but the two flows are
intended to ship on different app versions, and a shared file would have
to be split later anyway.
- *Two fully independent copies* — simplest per-flow, but every shared
refinement (spacing, motion, copy) would have to be made twice and would
drift.

## What Changed

- **Shared core** under `ui/src/components/onboarding/`:
`OnboardingScaffold` owns the full-screen shell and the single
`AnimatePresence` step crossfade, so both flows transition identically;
step views (Start / Company / Agent / Task), `FooterNav`, `AgentPreview`
and the motion constants are extracted for reuse.
- **`CloudOnboardingFlow`** — `start → company → agent → task`; mounted
in the real app via `OnboardingWizardVariant`. Behaviour matches the
retired wizard, including `previewMock` and the existing-company ("add
an agent") entry point.
- **`LocalOnboardingFlow`** — skips sign-in and adds an optional email
ask (with a privacy assurance), a local model/adapter step that hires
with `requireEnvProbe: true`, and a "star us on GitHub" interstitial
before completing. **Harness-only for now** — the real app still mounts
the cloud flow.
- **Deleted `OnboardingWizard.tsx`** (1,786 lines); updated its
Storybook stories and the `OnboardingWizardVariant` test to the new
components.
- **Orbiting 3D paperclip backdrop** behind the auth and welcome screens
(`three`), code-split so it only downloads on those screens; honours
`prefers-reduced-motion` and disposes its GL context on unmount.
- **`motion`** added for step transitions and the agent-capsule
choreography.
- Visual values routed through design tokens per `DESIGN.md`; `Stepper`
generalized to take a step total (backward compatible); `/design-guide`
page and the component index updated.
- **Standalone preview harness** (`ui/onboarding-preview.html`) with
`?flow=` and `?step=` for backend-free review, wired as a second Vite
rollup input.
- **Adapter env probe bound to the adapter it ran against.**
`hireLeadAgent` reused `adapterEnvResult` for any adapter, so when a
hire failed and the user picked a *different* local adapter and retried,
the previous adapter's verdict satisfied the `requireEnvProbe` guard
while the hire posted the new adapter's config — hiring it unprobed. The
cache is now keyed on the adapter type plus the exact config posted to
the test endpoint, the config is built once and shared by probe and
hire, a failed probe clears the cache, and `clearAdapterEnvResult()`
(called on adapter change) stops the step displaying a stale verdict.
Cloud is unaffected — it hires with `requireEnvProbe: false`. Reported
by Greptile.
- **E2E specs re-pointed at the new flow.** Four specs still drove the
deleted wizard (`onboarding`, `conference-room-typing-intro`,
`planning-mode-visual-verification`, `nux-phase4-screenshots`) and
failed with `element(s) not found` on `"Name your company"` /
`input[placeholder="Acme Corp"]`. Rather than repeat the new drive
sequence four times, `tests/e2e/onboarding-flow.ts` adds one driver per
step (`startCloudOnboarding`, `completeCompanyStep`,
`completeAgentStep`, `completeTaskStep`, `completeCloudOnboarding`) and
the specs import it, so the next flow change touches a single file. Two
now-dead `**/test-environment` route stubs went with it — the cloud flow
hires with `requireEnvProbe: false`, so that probe never fires.

## Verification

- `pnpm --filter @paperclipai/ui typecheck` — clean.
- `npx vitest run` over the onboarding suites
(`OnboardingWizardVariant`, `AgentCapsule`, `onboarding-launch`,
`onboarding-goal`, `onboarding-route`, `onboarding-adapter-config`) — 33
tests pass.
- `pnpm --filter @paperclipai/ui build` — succeeds; the three.js chunk
splits out separately (522 kB raw / 133 kB gzip) rather than entering
the main bundle.
- Both flows driven end-to-end in the preview harness in `previewMock`
(no database writes), plus the cloud flow rendered in the real
authenticated app at `/onboarding` to confirm the mount swap.
- The four re-pointed e2e specs pass locally against the new flow.
- New `ui/src/hooks/useOnboardingFlow.test.tsx` — 4 cases pinning the
adapter-probe cache (switch-adapter retry, cold path, explicit clear,
and the cloud flow's `requireEnvProbe: false`). Verified non-vacuous:
the switch-adapter case fails against the pre-fix code.
- Rebased onto current `master`; `pnpm-lock.yaml` is deliberately
**not** committed — `.github/workflows/pr.yml` regenerates it when a
manifest changes and shares it with downstream jobs as the `pr-lockfile`
artifact.

## Risks

- **Deleting `OnboardingWizard.tsx` is the one change that alters
existing app behaviour.** The cloud flow is intended to be
behaviour-equivalent, and its entry points are covered by the updated
`OnboardingWizardVariant` test, but this is the area to review most
closely.
- **Conflict risk with open PRs that touch the old wizard**: #9900,
#9501, #8982 and #6636 all modify
`ui/src/components/OnboardingWizard.tsx`, which this PR removes.
Whichever lands second will need its change re-applied to the new step
components. Flagging so ordering can be decided deliberately.
- **New dependencies**: `motion` and `three` (+ `@types/three`). `three`
is large, so it is lazily imported and code-split — it does not affect
the main bundle. Both are MIT.
- The **local flow is not reachable in the app** yet (harness/canary
only), so it carries no runtime risk today; wiring it up is a follow-up.
- The auth screens remain **presentational only** — they are not wired
to real auth, unchanged from before this PR.
- **Pre-existing, not introduced here:** `OnboardingWizardVariant`
renders outside `<Routes>` in `App.tsx`, so its `useParams()` never
resolves `:companyPrefix` and `/{prefix}/onboarding` opens the welcome
screen instead of jumping to the agent step. `master` has the identical
structure, so this PR faithfully ports existing behaviour; the working
"add an agent" entry is the launcher card behind the overlay, which is
what the screenshot spec drives. Worth a separate fix.

## Model Used

Claude Opus 5 (`claude-opus-5`) via Claude Code, with extended thinking
and tool use (repo search/edit, local test + build execution, and
browser-driven visual verification of the rendered flows). Portions of
the session also ran on `claude-opus-4-8` and `claude-fable-5`.

## 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 Opus 5 <noreply@anthropic.com>
2026-08-06 23:35:05 -07:00

484 lines
17 KiB
TypeScript

// Backend orchestration for onboarding, extracted from OnboardingWizard so a
// new presentational onboarding flow can reuse the exact company/goal/agent/
// issue creation logic without duplicating API calls or query invalidation.
//
// This hook owns the "created entity" ids and the adapter-environment probe
// state; it does NOT own user-entered form state (company name, mission, agent
// name, adapter selection) — callers pass those into the action functions. It
// also does not navigate: launchFirstTask returns the target company prefix and
// lets the caller route, keeping routing a UI concern.
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
import { companiesApi } from "@/api/companies";
import { goalsApi } from "@/api/goals";
import { agentsApi } from "@/api/agents";
import { approvalsApi } from "@/api/approvals";
import { issuesApi } from "@/api/issues";
import { projectsApi } from "@/api/projects";
import { queryKeys } from "@/lib/queryKeys";
import { parseOnboardingGoalInput } from "@/lib/onboarding-goal";
import { composeCeoInstructions } from "@/lib/ceo-instructions";
import { buildNewAgentRuntimeConfig } from "@/lib/new-agent-runtime-config";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId,
selectReusableOnboardingProject,
} from "@/lib/onboarding-launch";
import {
buildOnboardingAdapterConfig,
type OnboardingAdapterConfigInput,
} from "@/lib/onboarding-adapter-config";
import {
DEFAULT_TASK_DESCRIPTION,
DEFAULT_TASK_TITLE,
} from "@/lib/onboarding-constants";
/** Adapter selection inputs shared by the env probe and the hire action. */
export type AdapterInput = Omit<OnboardingAdapterConfigInput, "forceUnsetAnthropicApiKey">;
/** Context used to seed the lead agent's instructions file. */
export interface OnboardingInstructionsContext {
companyName: string;
companyGoal: string;
growPath: boolean;
growWorkflows: string;
growPainPoints: string;
growAutomate: string;
q1: string;
q2: string;
q3: string;
q4: string;
}
export interface HireLeadAgentInput {
agentName: string;
adapter: AdapterInput;
instructions: OnboardingInstructionsContext;
/**
* When true (local adapters), require a successful adapter-environment probe
* before hiring. Mirrors the wizard: hire is aborted only if the probe cannot
* run at all (returns null), not if it returns a fail status.
*/
requireEnvProbe: boolean;
}
export interface CreatedOnboardingEntities {
createdCompanyId: string | null;
createdCompanyPrefix: string | null;
createdAgentId: string | null;
createdCompanyGoalId: string | null;
createdProjectId: string | null;
createdIssueRef: string | null;
}
export function useOnboardingFlow(initial?: Partial<CreatedOnboardingEntities>) {
const queryClient = useQueryClient();
const { setSelectedCompanyId } = useCompany();
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
initial?.createdCompanyId ?? null,
);
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<string | null>(
initial?.createdCompanyPrefix ?? null,
);
const [createdAgentId, setCreatedAgentId] = useState<string | null>(
initial?.createdAgentId ?? null,
);
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
initial?.createdCompanyGoalId ?? null,
);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(
initial?.createdProjectId ?? null,
);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(
initial?.createdIssueRef ?? null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [adapterEnvResult, setAdapterEnvResult] =
useState<AdapterEnvironmentTestResult | null>(null);
const [adapterEnvError, setAdapterEnvError] = useState<string | null>(null);
const [adapterEnvLoading, setAdapterEnvLoading] = useState(false);
const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] = useState(false);
const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
/**
* Identifies the adapter selection that produced `adapterEnvResult`. The local
* flow lets the user pick a different adapter and retry after a failed hire,
* so a cached probe result is only evidence about the adapter it actually
* ran against — see `hireLeadAgent`.
*/
const [adapterEnvProbedKey, setAdapterEnvProbedKey] = useState<string | null>(null);
function buildAdapterConfig(adapter: AdapterInput): Record<string, unknown> {
return buildOnboardingAdapterConfig({ ...adapter, forceUnsetAnthropicApiKey });
}
/**
* Stable identity for a probe: the adapter type plus the exact config posted
* to the environment-test endpoint. `buildOnboardingAdapterConfig` builds its
* object in a fixed key order for a given adapter, so `JSON.stringify` is
* stable across calls.
*/
function adapterProbeKey(
adapterType: string,
adapterConfig: Record<string, unknown>,
): string {
return JSON.stringify([adapterType, adapterConfig]);
}
/**
* Forget any cached probe result. Callers invoke this when the user changes
* the adapter selection, so neither the UI nor `hireLeadAgent` keeps showing
* or trusting a verdict about the adapter that is no longer selected.
*/
function clearAdapterEnvResult(): void {
setAdapterEnvResult(null);
setAdapterEnvError(null);
setAdapterEnvProbedKey(null);
}
/**
* Create the company + its company-level goal from the mission. Guarded so
* calling it again after a company exists is a no-op that returns the
* already-created ids.
*/
async function createCompanyAndGoal(input: {
companyName: string;
companyGoal: string;
}): Promise<{ companyId: string; companyPrefix: string; goalId: string | null } | null> {
if (createdCompanyId) {
return {
companyId: createdCompanyId,
companyPrefix: createdCompanyPrefix ?? "",
goalId: createdCompanyGoalId,
};
}
setLoading(true);
setError(null);
try {
const company = await companiesApi.create({ name: input.companyName.trim() });
setCreatedCompanyId(company.id);
setCreatedCompanyPrefix(company.issuePrefix);
setSelectedCompanyId(company.id);
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
const parsedGoal = parseOnboardingGoalInput(input.companyGoal);
const goal = await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description ? { description: parsedGoal.description } : {}),
level: "company",
status: "active",
});
setCreatedCompanyGoalId(goal.id);
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(company.id) });
return { companyId: company.id, companyPrefix: company.issuePrefix, goalId: goal.id };
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create company");
return null;
} finally {
setLoading(false);
}
}
async function runAdapterEnvironmentTest(
adapter: AdapterInput,
adapterConfigOverride?: Record<string, unknown>,
): Promise<AdapterEnvironmentTestResult | null> {
if (!createdCompanyId) {
setAdapterEnvError(
"Create or select a company before testing adapter environment.",
);
return null;
}
setAdapterEnvLoading(true);
setAdapterEnvError(null);
const adapterConfig = adapterConfigOverride ?? buildAdapterConfig(adapter);
try {
const result = await agentsApi.testEnvironment(createdCompanyId, adapter.adapterType, {
adapterConfig,
});
setAdapterEnvResult(result);
setAdapterEnvProbedKey(adapterProbeKey(adapter.adapterType, adapterConfig));
return result;
} catch (err) {
setAdapterEnvError(
err instanceof Error ? err.message : "Adapter environment test failed",
);
setAdapterEnvResult(null);
setAdapterEnvProbedKey(null);
return null;
} finally {
setAdapterEnvLoading(false);
}
}
/**
* Clear ANTHROPIC_API_KEY in the adapter config and re-probe. Mirrors the
* wizard's remediation when a stray key overrides the Claude subscription.
*/
async function unsetAnthropicApiKeyAndRetry(adapter: AdapterInput): Promise<void> {
if (!createdCompanyId || unsetAnthropicLoading) return;
setUnsetAnthropicLoading(true);
setError(null);
setAdapterEnvError(null);
setForceUnsetAnthropicApiKey(true);
const configWithUnset = (() => {
const config = buildAdapterConfig(adapter);
const env =
typeof config.env === "object" &&
config.env !== null &&
!Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {};
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
return config;
})();
try {
if (createdAgentId) {
await agentsApi.update(createdAgentId, { adapterConfig: configWithUnset }, createdCompanyId);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
}
const result = await runAdapterEnvironmentTest(adapter, configWithUnset);
if (result?.status === "fail") {
setError(
"Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing.",
);
}
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to unset ANTHROPIC_API_KEY and retry.",
);
} finally {
setUnsetAnthropicLoading(false);
}
}
/**
* Hire the lead agent (role: ceo), auto-approve any board approval, and seed
* its instructions file. Guarded so a second call after an agent exists is a
* no-op. Returns null on failure (error is set) or when a required env probe
* cannot run.
*/
async function hireLeadAgent(
input: HireLeadAgentInput,
): Promise<{ agentId: string } | null> {
if (!createdCompanyId) return null;
if (createdAgentId) return { agentId: createdAgentId };
setLoading(true);
setError(null);
const adapterConfig = buildAdapterConfig(input.adapter);
try {
if (input.requireEnvProbe) {
// Reuse the cached probe only when it ran against this exact adapter
// selection. After a failed hire the user can go back and pick a
// different adapter, and the previous adapter's verdict says nothing
// about the new one.
const cacheHit =
adapterEnvResult !== null &&
adapterEnvProbedKey === adapterProbeKey(input.adapter.adapterType, adapterConfig);
const result = cacheHit
? adapterEnvResult
: await runAdapterEnvironmentTest(input.adapter, adapterConfig);
if (!result) return null;
}
const hire = await agentsApi.hire(createdCompanyId, {
name: input.agentName.trim(),
role: "ceo",
adapterType: input.adapter.adapterType,
adapterConfig,
runtimeConfig: buildNewAgentRuntimeConfig(),
});
if (hire.approval) {
await approvalsApi.approve(
hire.approval.id,
"Approved during onboarding first-agent setup.",
);
queryClient.invalidateQueries({
queryKey: queryKeys.approvals.list(createdCompanyId),
});
}
const agent = hire.agent;
setCreatedAgentId(agent.id);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
// Seed the CEO's instructions file so the agent always has company
// context + a hiring-plan output rule. Non-fatal on failure.
try {
const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId);
await agentsApi.saveInstructionsFile(
agent.id,
{
path: bundle.entryFile,
content: composeCeoInstructions({
companyName: input.instructions.companyName,
companyGoal: input.instructions.companyGoal,
growPath: input.instructions.growPath,
growWorkflows: input.instructions.growWorkflows,
growPainPoints: input.instructions.growPainPoints,
growAutomate: input.instructions.growAutomate,
q1: input.instructions.q1,
q2: input.instructions.q2,
q3: input.instructions.q3,
q4: input.instructions.q4,
}),
},
createdCompanyId,
);
} catch (err) {
console.warn("Failed to seed CEO instructions:", err);
}
return { agentId: agent.id };
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create agent");
return null;
} finally {
setLoading(false);
}
}
/**
* Ensure the onboarding goal + project exist, create the first task assigned
* to the lead agent, select the company, and return the target company prefix
* for the caller to navigate to. Idempotent across the goal/project/issue it
* creates. Requires a created company + agent.
*/
async function launchFirstTask(input?: {
title?: string;
description?: string;
}): Promise<{ companyId: string; companyPrefix: string | null } | null> {
if (!createdCompanyId || !createdAgentId) {
setError(
"Onboarding state is incomplete. Please restart onboarding and try again.",
);
return null;
}
setLoading(true);
setError(null);
try {
let goalId = createdCompanyGoalId;
if (!goalId) {
const goals = await goalsApi.list(createdCompanyId);
goalId = selectDefaultCompanyGoalId(goals);
setCreatedCompanyGoalId(goalId);
}
let projectId = createdProjectId;
if (!projectId) {
const projects = await projectsApi.list(createdCompanyId);
const existingOnboardingProject = selectReusableOnboardingProject(projects);
if (existingOnboardingProject) {
projectId = existingOnboardingProject.id;
} else {
const project = await projectsApi.create(
createdCompanyId,
buildOnboardingProjectPayload(goalId),
);
projectId = project.id;
queryClient.invalidateQueries({
queryKey: queryKeys.projects.list(createdCompanyId),
});
}
setCreatedProjectId(projectId);
}
if (!createdIssueRef) {
const issue = await issuesApi.create(
createdCompanyId,
buildOnboardingIssuePayload({
title: input?.title ?? DEFAULT_TASK_TITLE,
description: input?.description ?? DEFAULT_TASK_DESCRIPTION,
assigneeAgentId: createdAgentId,
projectId,
goalId,
}),
);
setCreatedIssueRef(issue.identifier ?? issue.id);
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId),
});
}
setSelectedCompanyId(createdCompanyId);
return { companyId: createdCompanyId, companyPrefix: createdCompanyPrefix };
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to launch first task");
return null;
} finally {
setLoading(false);
}
}
function setCreatedCompany(company: { id: string; prefix: string | null }) {
setCreatedCompanyId(company.id);
setCreatedCompanyPrefix(company.prefix);
}
function reset() {
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
setCreatedCompanyGoalId(null);
setCreatedProjectId(null);
setCreatedIssueRef(null);
setLoading(false);
setError(null);
setAdapterEnvResult(null);
setAdapterEnvError(null);
setAdapterEnvLoading(false);
setAdapterEnvProbedKey(null);
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
}
return {
// created entity state
createdCompanyId,
createdCompanyPrefix,
createdAgentId,
createdCompanyGoalId,
createdProjectId,
createdIssueRef,
setCreatedCompany,
setCreatedCompanyPrefix,
// status
loading,
error,
setError,
// adapter environment probe
adapterEnvResult,
adapterEnvError,
adapterEnvLoading,
forceUnsetAnthropicApiKey,
unsetAnthropicLoading,
buildAdapterConfig,
runAdapterEnvironmentTest,
clearAdapterEnvResult,
unsetAnthropicApiKeyAndRetry,
// actions
createCompanyAndGoal,
hireLeadAgent,
launchFirstTask,
reset,
};
}
export type OnboardingFlow = ReturnType<typeof useOnboardingFlow>;