Onboarding: model source tiles, one input canvas, and Storybook coverage for the agent arc (#12613)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - New customers meet it through onboarding, whose last three steps run
inside the tenant: create an agent, connect a model, review
> - The connect step is the one that decides whether the agent can run
at all, and it had drifted — three contributors changed it in parallel,
and its visual language no longer matched the rest of the flow
> - It also could not be looked at without a provisioned stack, so
defects in it were only found by walking a real signup, and the review
step behind it could not be reached at all when it failed
> - This pull request brings the visual work onto the sign-in behaviour
that already shipped, and adds Storybook coverage for all three steps
> - The benefit is that the step is easier to read, and that it can now
be inspected and driven before it ships rather than after

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The onboarding connect-a-model step. It presented the model choice as a
dropdown plus an "Advanced settings" disclosure, and put each credential
type in a different place, so the controls below moved whenever the
choice changed.

**Subsystem affected**
Tenant onboarding wizard (`ui/src/components/OnboardingWizard.tsx`) and
its Storybook coverage.

**Current behavior**
The step offered every registered adapter through a disclosure.
Credential entry appeared in a different shape per source. None of the
three agent-arc steps could be rendered outside a provisioned cloud
stack, so the sign-in panel and the review step were only reachable by
walking a real signup.

**Proposed behavior**
Two brand tiles for the recommended sources, a link that switches
between subscription and API-key credentials, and one canvas that holds
whichever input the current choice needs. Storybook stories mount the
real wizard against fixtures and walk it forward, so every step and its
states can be inspected locally.

**Reason and benefit**
The step reads as one decision rather than three scattered ones, and its
furniture stays still while the choice changes. The stories mean a
regression in it is visible before release instead of during a signup.

**Breaking changes**
No API or schema change. One behavioural narrowing, described under
Risks.

## What Changed

- Replaces the adapter dropdown and "Advanced settings" disclosure with
`ModelSourceTiles` — brand tiles for Claude Code and Codex.
- Adds `CredentialModeLink`, a text toggle between subscription sign-in
and API keys, replacing the disclosure.
- Adds `ConnectInputCanvas`: one surface that holds the sign-in panel or
the API-key field and resizes between them, so the Connect button below
does not move.
- Keeps the existing sign-in behaviour unchanged. `AgentConfigForm`
changes are presentation only — the provider name in the title, the CTA
wording, and `space-y` to `gap`. No change to the login mutations,
queries, or session handling.
- Restores the sleep marks on the dormant agent for the two steps before
the hire.
- Adds Storybook stories for all three agent-arc steps, with fixtures
for the environments, auth signal, both adapters' login flows, and the
hire.
- Copy: names the provider being signed in to ("Sign in to
Anthropic"/"Sign in to OpenAI"), and drops "Clippy" from the agent-name
helper text.

## Verification

- `pnpm vitest run src/components storybook` in `ui/` — 139 tests over
the touched suites, 1986 across `src/components`.
- `pnpm typecheck` in `ui/` — clean.
- Storybook, `Onboarding/Agent arc`: walk each story. Step 1 has no Back
button, steps 2 and 3 do.
- The sign-in gate: on `Connect a model`, press Connect without signing
in. It holds on step 2 and reports "No working authentication was
found." On `Review`, which fixtures an authenticated signal, Connect
reaches the review step.
- Both providers' login flows: press Sign in on the Claude tile for the
authorization URL and browser-code field, and on the Codex tile for the
device URL and code.
- The Claude sign-in was also walked end to end on a staging tenant,
including the OAuth redirect and pasting the code back.

## Risks

- **Onboarding now offers two model sources instead of every registered
adapter.** `ModelSourceTiles` is fed the `recommended` set, which is
`claude_local` and `codex_local`; Gemini, Cursor, Grok, Kimi, OpenCode
and Paperclip Runner are no longer selectable *during onboarding*. This
is deliberate. The full list is unchanged in agent settings, which is
where an adapter can still be switched after the agent exists, and
adding a source back is one `recommended: true` in
`adapter-display-registry.ts`. Flagging it because it is the one
behavioural narrowing here and it is not visible from the diffstat.
- The API key entered on this step is held in component state and
deliberately never written to the onboarding draft, because that draft
is `localStorage`. A customer who leaves mid-step re-enters the key;
that is the intended trade.
- Storybook-only risk: the fixtures now answer the environment test from
the story's auth state. If a future change moves the hire's gate off the
`adapter_auth_missing` check code, the stories would keep passing while
the product regressed. The gate is asserted in the adapter packages' own
tests, not here.
- Motion changes are low risk and reversible: the input canvas animates
its contents only, and its container was deliberately left unanimated
after an animated wrapper clipped the sign-in panel.

## Model Used

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

## 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>
This commit is contained in:
Tonio 2026-09-01 09:57:46 -07:00 committed by GitHub
parent 86ebdf842e
commit 42c6f8a424
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2585 additions and 361 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ node_modules/
**/node_modules
**/node_modules/
dist/
dist-preview/
packages/paperclip-runner/runner/target/
ui/storybook-static/
.env

View File

@ -93,9 +93,17 @@ test.describe("NUX Phase 4 visual QA", () => {
await page.evaluate(() => window.localStorage.clear());
await openWizard(page);
// Reach the full-screen front door (step 0): either it shows directly or
// "← Back to start" returns to it from the create step.
// the naming step's Back returns to it.
//
// That control used to be a "← Back to start" text link. The naming step now
// wears the same footer pair as the steps after it, so its Back is labelled
// like theirs — it still lands on the front door, because the front door is
// what sits behind step 1.
//
// Exact, because the progress strip's segments are buttons with their own
// labels and an unanchored /Back/ would match more than one.
if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) {
await page.getByRole("button", { name: /Back to start/ }).click();
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await expect(
page.getByRole("heading", { name: "Welcome to Paperclip" }),

View File

@ -222,12 +222,20 @@ test.describe("Onboarding wizard", () => {
// Step 4 (Connect a model): the default adapter is claude_local, and the
// signal above reports no ready credential, so the login panel must show
// with no button to reuse a saved login.
await expect(page.getByText("Sign in to the environment")).toBeVisible({
//
// The panel names the provider rather than the plumbing it runs on, so this
// title is per-adapter. "Sign in to the environment" is now only the fallback
// for an adapter with no known provider name, which claude_local is not.
await expect(page.getByText("Sign in to Anthropic")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0);
await page.getByRole("button", { name: /^Connect/ }).click();
// Exact, because the progress strip's segments are buttons too and one of
// them is labelled "Connect a model" for assistive tech. An unanchored
// /^Connect/ matches both it and this CTA, which is a strict-mode violation
// rather than a wrong click — Playwright refuses instead of guessing.
await page.getByRole("button", { name: "Connect", exact: true }).click();
// The failed test blocks the hire and shows its own checks.
await expect(page.getByText("The claude CLI was not found on this host.")).toBeVisible({

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en" class="dark" style="color-scheme: dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<title>Connect a model — Paperclip onboarding</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<!--
No theme bootstrap script, unlike the app's index.html: this page is the
dark design and has no theme switch, so `dark` is simply set above rather
than resolved from localStorage.
-->
<style>
/*
Unlayered on purpose. index.css pins `html, body { height: 100% }` with
`body { overflow: hidden }` inside @layer base — correct for the real
shell, which scrolls inside its own panes, but this page has no inner
scroll container, so on a short viewport it would clip the step's footer
with no way to reach it. Unlayered CSS wins over @layer regardless of
load order, and living in this entry keeps the app untouched.
*/
html,
body {
height: auto;
min-height: 100%;
overflow-y: auto;
}
/*
No height is imposed on #root: the centring wrapper measures the
viewport directly with `min-h-dvh`, so handing it a percentage height to
inherit would only give it a second, weaker mechanism to disagree with.
*/
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/connect-model-preview-main.tsx"></script>
</body>
</html>

View File

@ -16,11 +16,12 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:preview": "vite build --config vite.preview.config.mjs",
"storybook": "storybook dev -p 6006 -c storybook/.storybook",
"build-storybook": "storybook build -c storybook/.storybook -o storybook-static",
"preview": "vite preview",
"typecheck": "tsc -b",
"clean": "rm -rf dist storybook-static tsconfig.tsbuildinfo",
"clean": "rm -rf dist dist-preview storybook-static tsconfig.tsbuildinfo",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},

View File

@ -0,0 +1,17 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="codex-color 1" clip-path="url(#clip0_0_21)">
<g id="Vector">
</g>
<g id="claude-color 1" clip-path="url(#clip1_0_21)">
<path id="Vector_2" d="M8.31658 18.6254L12.6433 16.199L12.7166 15.9882L12.6433 15.8708H12.4333L11.7092 15.8268L9.236 15.7599L7.09192 15.671L5.01475 15.5592L4.49133 15.4483L4 14.802L4.05042 14.4793L4.49042 14.1851L5.11925 14.2401L6.51258 14.3345L8.60075 14.4793L10.1151 14.5683L12.36 14.802H12.7166L12.767 14.6581L12.6442 14.5683L12.5497 14.4793L10.3883 13.0163L8.04892 11.469L6.82425 10.578L6.16058 10.1279L5.82692 9.70442L5.68208 8.78042L6.28342 8.11858L7.091 8.17358L7.29725 8.2295L8.11583 8.85833L9.86483 10.2113L12.1483 11.8916L12.4828 12.1703L12.6158 12.0758L12.6332 12.0089L12.4828 11.7578L11.2408 9.51558L9.91525 7.23308L9.32492 6.28708L9.16908 5.71967C9.10971 5.50173 9.07768 5.27726 9.07375 5.05142L9.75942 4.12283L10.138 4L11.051 4.12283L11.436 4.4565L12.0043 5.75267L12.9228 7.79592L14.3482 10.5734L14.7663 11.3966L14.989 12.1593L15.0724 12.393H15.2173V12.2592L15.3346 10.6953L15.5518 8.77492L15.7627 6.3045L15.836 5.60783L16.1807 4.77367L16.8654 4.32267L17.4008 4.57933L17.8408 5.20725L17.7793 5.61425L17.5172 7.311L17.0048 9.97208L16.6711 11.7522H16.8654L17.0882 11.5304L17.9911 10.3333L19.5054 8.44125L20.1746 7.68958L20.9538 6.86092L21.4552 6.46583H22.4021L23.0987 7.50075L22.7871 8.56958L21.8118 9.80433L21.0042 10.8512L19.8455 12.4095L19.1213 13.6562L19.1883 13.757L19.3606 13.7387L21.9786 13.1832L23.393 12.9265L25.0806 12.6378L25.8442 12.9934L25.9276 13.3555L25.6269 14.0952L23.822 14.5407L21.7054 14.9643L18.553 15.7095L18.5145 15.737L18.5594 15.7929L19.9793 15.9267L20.5862 15.9598H22.073L24.8413 16.166L25.5655 16.6445L26 17.2293L25.9276 17.6739L24.8138 18.2423L23.3105 17.8857L19.8006 17.0515L18.5979 16.7499H18.4311V16.8508L19.433 17.8298L21.2718 19.4889L23.5718 21.6247L23.6882 22.1546L23.393 22.5717L23.0813 22.5268L21.0601 21.0078L20.28 20.3231L18.5145 18.8381H18.3972V18.9939L18.8042 19.5888L20.9538 22.8164L21.0656 23.8064L20.9098 24.13L20.3524 24.3253L19.7401 24.2134L18.4806 22.4488L17.1835 20.4624L16.1358 18.6813L16.0074 18.7547L15.3896 25.4042L15.0999 25.7433L14.4317 26L13.8752 25.5774L13.5801 24.8927L13.8752 23.5397L14.2318 21.776L14.5206 20.3735L14.7827 18.6318L14.9386 18.0525L14.9276 18.014L14.7993 18.0305L13.4848 19.8336L11.4864 22.5332L9.90425 24.2244L9.52475 24.3747L8.8675 24.0356L8.92892 23.4288L9.2965 22.8888L11.4855 20.1058L12.8055 18.3807L13.658 17.3852L13.6525 17.2403H13.6021L7.78767 21.0133L6.75183 21.1472L6.30542 20.7292L6.36133 20.0453L6.57308 19.8226L8.32208 18.6199L8.31658 18.6254Z" fill="#D97757"/>
</g>
</g>
<defs>
<clipPath id="clip0_0_21">
<rect width="30" height="30" fill="white"/>
</clipPath>
<clipPath id="clip1_0_21">
<rect width="22" height="22" fill="white" transform="translate(4 4)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,17 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="codex-color 2" clip-path="url(#clip0_0_16)">
<g id="Vector">
</g>
<path id="Vector_2" d="M11.3298 4.18042C12.233 3.80881 13.2163 3.67455 14.186 3.79042C15.436 3.93417 16.5498 4.46542 17.5273 5.38417C17.5398 5.39667 17.5573 5.40542 17.5735 5.41042C17.5912 5.41476 17.6096 5.41476 17.6273 5.41042C18.8981 5.08224 20.2433 5.20369 21.4348 5.75417L21.4935 5.78167L21.6385 5.85292C22.8857 6.48505 23.8586 7.55169 24.3735 8.85167C24.6348 9.48917 24.7648 10.1529 24.7673 10.8454C24.7861 11.3604 24.7296 11.8754 24.5998 12.3742C24.5934 12.3993 24.5934 12.4257 24.6 12.4509C24.6066 12.476 24.6194 12.4991 24.6373 12.5179C25.3798 13.2767 25.8723 14.1804 26.116 15.2304C26.4773 17.0117 26.1073 18.6179 25.0073 20.0479L24.8373 20.2554C24.1087 21.0894 23.1525 21.6924 22.086 21.9904C22.0629 21.9972 22.0416 22.0093 22.0241 22.0258C22.0065 22.0423 21.993 22.0627 21.9848 22.0854C21.746 22.7742 21.506 23.3642 21.0598 23.9529C19.9348 25.4367 18.2823 26.2604 16.421 26.2504C14.9373 26.2429 13.6223 25.7004 12.4748 24.6229C12.4575 24.6068 12.4363 24.5955 12.4132 24.5902C12.3901 24.5849 12.3661 24.5859 12.3435 24.5929C11.8585 24.7492 11.3685 24.7717 10.8385 24.7654C9.99435 24.7586 9.16285 24.5594 8.40727 24.1829C7.61581 23.7907 6.92672 23.2193 6.39477 22.5142C6.20477 22.2617 6.01602 22.0242 5.87727 21.7429C5.68785 21.357 5.53308 20.955 5.41477 20.5417C5.16502 19.6012 5.159 18.6126 5.39727 17.6692C5.40511 17.6467 5.40767 17.6228 5.40477 17.5992C5.40049 17.576 5.38862 17.5549 5.37102 17.5392C4.79361 16.9547 4.35228 16.25 4.07852 15.4754C3.89646 14.9983 3.79058 14.4955 3.76477 13.9854C3.71936 13.3136 3.77882 12.6389 3.94102 11.9854C4.36227 10.5954 5.16852 9.50417 6.35727 8.71292C6.62227 8.53667 6.87352 8.39917 7.10852 8.30042C7.37727 8.18917 7.64602 8.09542 7.91602 8.01667C7.93532 8.01068 7.95283 8.00002 7.96701 7.98562C7.98118 7.97123 7.99158 7.95355 7.99727 7.93417C8.20229 7.19797 8.55484 6.51114 9.03352 5.91542C9.63694 5.14918 10.4278 4.55158 11.3298 4.18042ZM15.6823 17.3867C15.4791 17.3981 15.2881 17.4868 15.1483 17.6346C15.0085 17.7825 14.9306 17.9782 14.9306 18.1817C14.9306 18.3851 15.0085 18.5809 15.1483 18.7287C15.2881 18.8765 15.4791 18.9653 15.6823 18.9767H20.2273C20.3354 18.9827 20.4437 18.9667 20.5454 18.9295C20.6471 18.8922 20.7402 18.8347 20.8189 18.7603C20.8976 18.6858 20.9603 18.5961 21.0032 18.4967C21.046 18.3972 21.0681 18.29 21.0681 18.1817C21.0681 18.0733 21.046 17.9662 21.0032 17.8667C20.9603 17.7672 20.8976 17.6775 20.8189 17.6031C20.7402 17.5287 20.6471 17.4711 20.5454 17.4339C20.4437 17.3967 20.3354 17.3806 20.2273 17.3867H15.6823ZM10.5773 11.5379C10.4691 11.3617 10.2966 11.2345 10.0964 11.1832C9.89609 11.1319 9.68371 11.1605 9.50413 11.2629C9.32456 11.3654 9.19187 11.5337 9.13414 11.7322C9.0764 11.9307 9.09815 12.1439 9.19477 12.3267L10.7848 15.1067L9.20227 17.7767C9.149 17.8665 9.11396 17.966 9.09914 18.0694C9.08432 18.1729 9.09001 18.2782 9.1159 18.3794C9.14178 18.4806 9.18735 18.5757 9.25 18.6593C9.31264 18.743 9.39115 18.8134 9.48102 18.8667C9.57089 18.9199 9.67038 18.955 9.77379 18.9698C9.87721 18.9846 9.98253 18.9789 10.0837 18.953C10.185 18.9272 10.2801 18.8816 10.3637 18.8189C10.4473 18.7563 10.5178 18.6778 10.571 18.5879L12.3885 15.5192C12.4602 15.3983 12.4985 15.2605 12.4996 15.12C12.5007 14.9795 12.4645 14.8412 12.3948 14.7192L10.5773 11.5379Z" fill="url(#paint0_linear_0_16)"/>
</g>
<defs>
<linearGradient id="paint0_linear_0_16" x1="14.9998" y1="3.75042" x2="14.9998" y2="26.2504" gradientUnits="userSpaceOnUse">
<stop stop-color="#B1A7FF"/>
<stop offset="0.5" stop-color="#7A9DFF"/>
<stop offset="1" stop-color="#3941FF"/>
</linearGradient>
<clipPath id="clip0_0_16">
<rect width="30" height="30" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -611,7 +611,7 @@ async function runTest(container: HTMLElement) {
}
async function startLogin(container: HTMLElement) {
await clickByText(container, "Log in");
await clickByText(container, "Sign in");
await flushReact();
}
@ -1089,11 +1089,11 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderCodexSandbox();
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("hides the Codex login for a provider without the login pseudo-terminal capability", async () => {
@ -1118,7 +1118,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the login affordance and the displayed-code panel for a third adapter with a projected login capability", async () => {
@ -1129,13 +1129,13 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderVendorSandbox();
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
// The projected capability gates the login affordance on for the third
// adapter.
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
await startLogin(result.container);
@ -1154,11 +1154,11 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderGrokSandbox();
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
await startLogin(result.container);
@ -1171,11 +1171,11 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderClaudeSandbox();
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("hides the Login button for a Claude sandbox whose provider lacks the setup-token login capability", async () => {
@ -1199,7 +1199,7 @@ describe("AgentConfigForm environment selector", () => {
// E2B does not advertise the setup-token login capability, so the panel
// stays hidden even after the auth-missing check.
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("hides the Login button for a Daytona sandbox while the capabilities report no setup-token support", async () => {
@ -1234,7 +1234,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("gates a pseudo-terminal login on the provider pty capability for a non-Claude adapter", async () => {
@ -1260,7 +1260,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows a pseudo-terminal login for a non-Claude adapter when the provider advertises pty support", async () => {
@ -1285,7 +1285,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("shows the Login button when a parent lifts the test feedback and renders the panel from the descriptor", async () => {
@ -1353,11 +1353,11 @@ describe("AgentConfigForm environment selector", () => {
});
await flushReact();
expect(findButton(container, "Log in")).toBeFalsy();
expect(findButton(container, "Sign in")).toBeFalsy();
await runTest(container);
expect(findButton(container, "Log in")).toBeTruthy();
expect(findButton(container, "Sign in")).toBeTruthy();
});
it("does not show the Login button when the Test result has no adapter_auth_missing check", async () => {
@ -1366,7 +1366,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("does not show the Login button when the effective environment is Local", async () => {
@ -1380,7 +1380,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the Login button for an agent with no own environment under the managed-sandbox-only policy", async () => {
@ -1416,11 +1416,11 @@ describe("AgentConfigForm environment selector", () => {
);
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("keeps the Login button hidden under the managed-sandbox-only policy when no managed sandbox is available", async () => {
@ -1447,7 +1447,7 @@ describe("AgentConfigForm environment selector", () => {
);
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("starts a login session for the effective sandbox and shows the code and the authentication URL", async () => {
@ -1561,8 +1561,8 @@ describe("AgentConfigForm environment selector", () => {
"codex_local",
"session-1",
);
// The panel resets: the Log in button is available again and the code is gone.
const login = findButton(result.container, "Log in");
// The panel resets: the Sign in button is available again and the code is gone.
const login = findButton(result.container, "Sign in");
expect(login?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("WXYZ-1234");
@ -1603,7 +1603,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
await startLogin(result.container);
const startButton = findButton(result.container, "Log in");
const startButton = findButton(result.container, "Sign in");
expect(startButton).toBeTruthy();
expect(startButton?.disabled).toBe(true);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
@ -1673,7 +1673,7 @@ describe("AgentConfigForm environment selector", () => {
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
const select = result.container.querySelector("select");
await act(async () => {
@ -1685,7 +1685,7 @@ describe("AgentConfigForm environment selector", () => {
});
await flushReact();
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the authorization URL and a browser-code input for a Claude sandbox", async () => {
@ -1821,7 +1821,7 @@ describe("AgentConfigForm environment selector", () => {
]);
roots.push(result.root);
// Log in on the first sandbox. The stored state adds the fixed
// Sign in on the first sandbox. The stored state adds the fixed
// `CLAUDE_CODE_OAUTH_TOKEN` binding and the non-secret claim to the form.
await runTest(result.container);
await startLogin(result.container);
@ -2036,7 +2036,7 @@ describe("AgentConfigForm environment selector", () => {
</QueryClientProvider>,
);
});
await flushUntil(() => Boolean(findButton(container, "Log in")));
await flushUntil(() => Boolean(findButton(container, "Sign in")));
expect(findButton(container, "Use saved login")).toBeUndefined();
expect(onApplyStored).not.toHaveBeenCalled();
@ -2063,7 +2063,7 @@ describe("AgentConfigForm environment selector", () => {
// The panel shows a fixed message and returns to its start state. The Log in
// button is available again.
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Log in")?.disabled).toBe(false);
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
// The panel never shows the provider failure message, which could carry a
// secret.
expect(result.container.textContent).not.toContain("the provider rejected the browser code");
@ -2088,7 +2088,7 @@ describe("AgentConfigForm environment selector", () => {
);
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Log in")?.disabled).toBe(false);
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
});
it("shows a terminal failure and stops polling on a status 404 from server cleanup", async () => {
@ -2119,7 +2119,7 @@ describe("AgentConfigForm environment selector", () => {
// The panel shows the fixed failure message and returns to its start state.
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Log in")?.disabled).toBe(false);
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
// The panel shows no credential material: no authorization URL and no
// browser-code input.
@ -2156,9 +2156,9 @@ describe("AgentConfigForm environment selector", () => {
"company-1",
"claude-session-1",
);
// The panel resets: the Log in button is available again, and the URL and the
// The panel resets: the Sign in button is available again, and the URL and the
// browser-code input are gone.
expect(findButton(result.container, "Log in")?.disabled).toBe(false);
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("https://claude.example.test/authorize");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
@ -2190,10 +2190,10 @@ describe("AgentConfigForm environment selector", () => {
"company-1",
"claude-session-1",
);
// The panel reset even though the cancel returned a 404: the Log in button is
// The panel reset even though the cancel returned a 404: the Sign in button is
// available again, and the URL and the browser-code input are gone. No error
// message remains.
expect(findButton(result.container, "Log in")?.disabled).toBe(false);
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("https://claude.example.test/authorize");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
@ -2229,9 +2229,9 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderClaudeSandbox();
await runTest(result.container);
// The panel shows the Log in button but no session started, so no active
// The panel shows the Sign in button but no session started, so no active
// session exists to cancel.
expect(findButton(result.container, "Log in")).toBeTruthy();
expect(findButton(result.container, "Sign in")).toBeTruthy();
await act(async () => {
result.root.unmount();
@ -2335,7 +2335,7 @@ describe("AgentConfigForm environment selector", () => {
await flushFake();
await clickFake(container, "Test");
await clickFake(container, "Log in");
await clickFake(container, "Sign in");
// The login is active: both polls have run at least once.
const statusCallsAtStart = mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length;
@ -2374,7 +2374,7 @@ describe("AgentConfigForm environment selector", () => {
"claude-session-1",
);
// The Log in button is available again, and the Cancel button is gone.
expect(findButton(container, "Log in")?.disabled).toBe(false);
expect(findButton(container, "Sign in")?.disabled).toBe(false);
expect(findButton(container, "Cancel")).toBeFalsy();
// Both polls stopped. A further ten seconds adds no new poll call.
@ -2597,7 +2597,7 @@ describe("AgentConfigForm create-mode Claude OAuth binding", () => {
// The panel shows a fixed, non-secret message and returns to its start state.
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Log in")?.disabled).toBe(false);
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(result.container.textContent).not.toContain(
"the provider rejected the stored-session claim",
);

View File

@ -2192,6 +2192,26 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
// The login panel dispatcher. It picks the panel from the projected panel mode,
// not from the adapter name. The `submitted_browser_code` mode shows the
// submitted-browser-code panel; every other mode shows the displayed-code panel.
/**
* The account a source signs in to, named where one is known.
*
* "Sign in to the environment" describes the plumbing a login performed inside
* a sandbox and is the honest label when the provider is unknown. But for the
* two sources onboarding offers, the customer is signing in to Anthropic or to
* OpenAI, and naming that is what tells them which password manager entry to
* reach for. The generic wording stays for anything not listed, where a guess
* would be worse than a description.
*/
const ADAPTER_LOGIN_PROVIDER: Record<string, string> = {
claude_local: "Anthropic",
codex_local: "OpenAI",
};
function adapterLoginTitle(adapterType: string): string {
const provider = ADAPTER_LOGIN_PROVIDER[adapterType];
return provider ? `Sign in to ${provider}` : "Sign in to the environment";
}
export function AdapterLoginPanel(props: AdapterLoginPanelProps) {
const getCapabilities = useAdapterCapabilities();
const panelMode = getCapabilities(props.adapterType).login?.panelMode;
@ -2266,9 +2286,14 @@ function DisplayedCodeLoginPanel({
const startDisabled = startLogin.isPending || isActive;
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 space-y-2">
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 flex flex-col gap-2">
{/* `gap`, not `space-y`: the live region below collapses to
`display: none` whenever it has nothing to announce, and
`space-y` would still put its 8px on the row above dead space
inside the card that pushes the row off centre. A gap only
applies between children that render. */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">Sign in to the environment</span>
<span className="text-xs font-medium text-foreground">{adapterLoginTitle(adapterType)}</span>
<div className="flex items-center gap-1.5">
{isActive && (
<Button
@ -2290,7 +2315,7 @@ function DisplayedCodeLoginPanel({
disabled={startDisabled}
onClick={() => startLogin.mutate()}
>
Log in
Sign in
</Button>
</div>
</div>
@ -2397,6 +2422,7 @@ const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login aga
// only the server `stored` state as success, and it never shows the OAuth token.
function SubmittedBrowserCodeLoginPanel({
companyId,
adapterType,
environmentId,
onStored,
onApplyStored,
@ -2714,9 +2740,14 @@ function SubmittedBrowserCodeLoginPanel({
};
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 space-y-2">
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 flex flex-col gap-2">
{/* `gap`, not `space-y`: the live region below collapses to
`display: none` whenever it has nothing to announce, and
`space-y` would still put its 8px on the row above dead space
inside the card that pushes the row off centre. A gap only
applies between children that render. */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">Sign in to the environment</span>
<span className="text-xs font-medium text-foreground">{adapterLoginTitle(adapterType)}</span>
<div className="flex items-center gap-1.5">
{isActive && (
<Button
@ -2755,7 +2786,7 @@ function SubmittedBrowserCodeLoginPanel({
disabled={startDisabled}
onClick={() => startLogin.mutate()}
>
{storedToken && !isActive && !isStored ? "Log in to replace" : "Log in"}
{storedToken && !isActive && !isStored ? "Sign in to replace" : "Sign in"}
</Button>
</div>
</div>

View File

@ -91,6 +91,12 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
const mockApprovalsApi = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
listMyUserSecrets: vi.fn(),
createUserSecretDefinition: vi.fn(),
createMyUserSecret: vi.fn(),
rotateMyUserSecret: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
create: vi.fn(),
}));
@ -122,6 +128,7 @@ vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi }));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/approvals", () => ({ approvalsApi: mockApprovalsApi }));
vi.mock("../api/secrets", () => ({ secretsApi: mockSecretsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi }));
@ -134,7 +141,12 @@ vi.mock("../adapters/metadata", () => ({ isVisualAdapterChoice: () => true }));
vi.mock("../adapters/adapter-display-registry", () => ({
getAdapterDisplay: (type: string) => ({
type,
recommended: false,
// Mirrors the real registry, where these two and only these two are
// `recommended`. A blanket `false` used to be harmless because every adapter
// then sat in the "Advanced settings" disclosure and was reachable anyway;
// with the step down to a tile row built from this flag, it made that row
// empty in every test and hid the surface under it.
recommended: type === "claude_local" || type === "codex_local",
label: type,
description: "",
icon: () => null,
@ -649,6 +661,160 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
// The Connect handler reuses a passing probe instead of re-running it, so the
// effect that clears the cache has to name every input to the configuration
// the probe tested. `credentialMode` and `apiKey` were missing from it, and
// the gap is reachable: the probe and the hire share one try/catch, so a hire
// that throws leaves the pass in state. Switching to a key and pressing
// Connect again then hired against a key nothing had tested.
/**
* Typing a key into this step must not put the key into the agent's stored
* configuration. That configuration is persisted and revisioned, so a plain
* value there is a live credential at rest in every copy of it which is
* what this step did before, and what the Claude token path has always
* avoided by holding a `user_secret_ref` instead.
*/
describe("an API key typed on the step", () => {
const KEY = "sk-ant-typed-by-the-customer";
// The canvas holding the key field only opens once a source is selected,
// and the tile row that selects one is built from this registry. The
// suite's default is empty, which leaves the step with no tiles, no
// canvas, and no field to type into.
beforeEach(() => {
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
// No definition and no stored value yet: the first customer to type a key.
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockSecretsApi.createUserSecretDefinition.mockResolvedValue({ id: "def-1" });
mockSecretsApi.createMyUserSecret.mockResolvedValue({ id: "secret-abc" });
mockSecretsApi.rotateMyUserSecret.mockResolvedValue({ id: "secret-existing" });
});
async function connectWithApiKey() {
const handles = await openConnectStep();
await handles.clickByText((t) => t.startsWith("Use API keys"));
const field = document.body.querySelector(
'input[type="password"]',
) as HTMLInputElement;
await act(async () => {
setControlledValue(field, KEY);
});
await flushReact();
await handles.clickByText((t) => t.startsWith("Connect"));
return handles;
}
it("is stored as the user's own secret and referenced, never carried in the hire", async () => {
const { root } = await connectWithApiKey();
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
const [, createBody] = mockSecretsApi.createMyUserSecret.mock.calls.at(-1) as [
string,
{ definitionKey: string; value: string },
];
expect(createBody.definitionKey).toBe("ANTHROPIC_API_KEY");
expect(createBody.value).toBe(KEY);
const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as {
adapterConfig: { env?: Record<string, unknown> };
};
// The same binding kind the subscription half of this step produces.
expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toEqual({
type: "user_secret_ref",
key: "ANTHROPIC_API_KEY",
version: "latest",
});
// The whole payload, not just that one field: the point is that the key
// is nowhere in what gets persisted, however it might be nested.
expect(JSON.stringify(hireBody)).not.toContain(KEY);
await act(async () => root.unmount());
});
// Onboarding is the first thing to need this definition, so it creates it.
it("creates the definition once, then reuses it", async () => {
await connectWithApiKey();
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{ definition: { id: "def-1", key: "ANTHROPIC_API_KEY" }, secret: null },
]);
const { root } = await connectWithApiKey();
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
// A second value against one definition is what the server refuses, so a
// customer who already has a key stored must rotate rather than add.
it("rotates an existing value instead of storing a second one", async () => {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{
definition: { id: "def-1", key: "ANTHROPIC_API_KEY" },
secret: { id: "secret-existing" },
},
]);
const { root } = await connectWithApiKey();
expect(mockSecretsApi.rotateMyUserSecret).toHaveBeenCalledWith(
expect.any(String),
"secret-existing",
{ value: KEY },
);
expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
// The one outcome that must never happen is a hire that falls back to
// embedding the key because storing it failed.
it("blocks the hire when the key cannot be stored", async () => {
mockSecretsApi.createMyUserSecret.mockRejectedValue(new Error("vault unreachable"));
const { root } = await connectWithApiKey();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Could not store the API key");
await act(async () => root.unmount());
});
it("stores one secret when Connect is pressed twice with the same key", async () => {
mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
const { root, clickByText } = await connectWithApiKey();
await clickByText((t) => t.startsWith("Connect"));
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
});
it("re-probes rather than reusing a pass when the credential mode changes", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "pass" as const,
checks: [],
testedAt: new Date().toISOString(),
});
// The hire fails, which is what leaves the passing probe behind.
mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Connect"));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
// Switch to API keys, which changes the configuration the hire will send.
await clickByText((t) => t.startsWith("Use API keys"));
await clickByText((t) => t.startsWith("Connect"));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
await act(async () => root.unmount());
});
it("does not open the create path on a cached warn result that holds adapter_auth_missing", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
@ -1550,21 +1716,21 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
it("shows the login panel for claude_local when the signal reports no ready credential", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
expect(document.body.textContent).toContain("Sign in to the environment");
expect(document.body.textContent).toContain("Sign in to Anthropic");
await act(async () => root.unmount());
});
it("shows the login panel for codex_local when the signal cannot decide", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "codex_local" });
expect(document.body.textContent).toContain("Sign in to the environment");
expect(document.body.textContent).toContain("Sign in to OpenAI");
await act(async () => root.unmount());
});
it("hides the login panel when the signal reports a ready credential", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
const { root } = await openStep4({ adapterType: "claude_local" });
expect(document.body.textContent).not.toContain("Sign in to the environment");
expect(document.body.textContent).not.toContain("Sign in to Anthropic");
await act(async () => root.unmount());
});
@ -1596,8 +1762,13 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await flushReact();
};
await clickByText((t) => t.startsWith("Advanced settings"));
await clickByText((t) => t === "codex_local");
// Straight to the tile. The adapter change used to be reached through an
// "Advanced settings" disclosure listing every non-recommended adapter;
// the step now offers Claude and Codex as tiles and spends that line on
// the credential switch instead. What is asserted below is unchanged —
// changing the source re-reads the signal — only the route there is.
// The tile's text is the label plus its credential tag, hence the prefix.
await clickByText((t) => t.startsWith("codex_local"));
expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith(
"company-new",
@ -1613,7 +1784,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
expect(document.body.textContent).not.toContain("Sign in to the environment");
expect(document.body.textContent).not.toContain("Sign in to Anthropic");
expect(mockAgentsApi.getAdapterAuthSignal).not.toHaveBeenCalled();
await act(async () => root.unmount());
});

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useMemo, useRef } from "react";
import type { CSSProperties } from "react";
import type { ComponentType, CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MotionConfig, motion } from "motion/react";
import type {
@ -11,6 +11,7 @@ import type {
} from "@paperclipai/shared";
import { AGENT_ROLES, AGENT_ROLE_LABELS, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import { AdapterLoginPanel } from "./AgentConfigForm";
import { secretsApi } from "../api/secrets";
import { Label } from "./ui/label";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@ -83,8 +84,19 @@ import {
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { FrontDoor } from "./FrontDoor";
import { PillGuy } from "./onboarding/PillGuy";
import { AGENT_ARC_WIZARD_STEPS, Stepper, agentArcStepFor } from "./onboarding/Stepper";
import { SleepingZs } from "./onboarding/SleepingZs";
import {
AGENT_ARC_WIZARD_STEPS,
ONBOARDING_STEP_LABELS,
ONBOARDING_WIZARD_STEPS,
Stepper,
agentArcStepFor,
onboardingStepPositionFor,
} from "./onboarding/Stepper";
import { AgentPreview } from "./onboarding/AgentPreview";
import { ModelSourceTiles, type CredentialMode } from "./onboarding/ModelSourceTiles";
import { CredentialModeLink } from "./onboarding/CredentialModeLink";
import { ApiKeyField, ConnectInputCanvas } from "./onboarding/ConnectInputCanvas";
import { FooterNav } from "./onboarding/FooterNav";
import { OnboardingHeading } from "./onboarding/OnboardingPrimitives";
import { DEFAULT_AGENT_ROLE } from "../lib/onboarding-agent-role";
@ -176,6 +188,52 @@ function adapterConfigHasAnthropicApiKey(config: Record<string, unknown>): boole
return binding.type === "secret_ref" || binding.type === "user_secret_ref";
}
/**
* Full-colour brand marks for the sources this step offers.
*
* The registry's own icons are monochrome, drawn to sit in dense config UI
* where a row of saturated logos would be noise. This step is the opposite
* case: two large tiles carrying the whole choice, where the brand is the
* fastest thing to recognise.
*
* Keyed by adapter type with a fallback, so the row stays registry-driven. An
* adapter with no brand file here still renders with its registry icon
* rather than a gap where a tile should be.
*/
const MODEL_SOURCE_BRAND_MARKS: Record<string, string> = {
claude_local: "/brands/claude-color.svg",
codex_local: "/brands/codex-color.svg",
};
/**
* The environment variable each source reads its key from.
*
* Named rather than described in the field above it, because the customer knows
* which key they are holding and does not know where this step will put it. The
* mapping already existed in this file as prose inside the environment-check
* hint; this is the same knowledge, in a form the key field can use.
*/
const API_KEY_ENV_KEYS: Record<string, string> = {
claude_local: ANTHROPIC_API_KEY_ENV_KEY,
codex_local: "OPENAI_API_KEY",
};
function apiKeyEnvKeyFor(adapterType: string): string {
return API_KEY_ENV_KEYS[adapterType] ?? "API_KEY";
}
function ModelSourceMark({
type,
Fallback,
}: {
type: string;
Fallback: ComponentType<{ className?: string }>;
}) {
const brand = MODEL_SOURCE_BRAND_MARKS[type];
if (!brand) return <Fallback className="size-full" />;
return <img src={brand} alt="" className="size-full" />;
}
// Exported so tests write/read the exact key the component uses, instead of
// duplicating the literal and silently drifting from it if it's ever renamed.
export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
@ -516,6 +574,22 @@ function OnboardingWizardInner({
useState(false);
const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
const [showMoreAdapters, setShowMoreAdapters] = useState(false);
/**
* Whether the connect step is asking for a subscription sign-in or an API key.
*
* Restored from the draft like everything else on this step: someone who
* picked keys, left, and came back should not be handed a sign-in panel they
* already said no to.
*/
const [credentialMode, setCredentialMode] = useState<CredentialMode>(
(saved?.credentialMode as CredentialMode) ?? "subscription",
);
/**
* The key itself, held only for as long as the wizard is open. It is written
* into the adapter config at hire time and never into the draft a draft is
* `localStorage`, and a provider key does not belong there.
*/
const [apiKey, setApiKey] = useState("");
// The owner's stored Claude subscription login, read right before the hire
// (see handleGiveHeartbeat). Onboarding applies it with no extra control,
// so nothing else reads this state yet.
@ -565,6 +639,13 @@ function OnboardingWizardInner({
// the binding cannot answer for a config that now does — see the reuse
// check in `handleGiveHeartbeat`.
const adapterEnvResultAppliedStoredLoginRef = useRef(false);
/**
* The secret a key typed on this step was stored as, remembered for the key it
* holds. Connect can be pressed more than once a hire that fails leaves the
* customer on the step to try again and without this each press would store
* another copy of the same credential.
*/
const apiKeySecretRef = useRef<{ key: string } | null>(null);
createdCompanyIdRef.current = createdCompanyId;
// The mission of the company actually in hand, which is not always the one
@ -757,6 +838,8 @@ function OnboardingWizardInner({
const state = {
step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url,
// The mode, never the key: this blob is localStorage.
credentialMode,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
@ -765,6 +848,7 @@ function OnboardingWizardInner({
}, [
effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url,
credentialMode,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
@ -892,6 +976,17 @@ function OnboardingWizardInner({
const authSignalStatus = authSignalQuery.data?.status ?? null;
const showAdapterLoginPanel =
canShowAdapterLogin && (authSignalStatus === "absent" || authSignalStatus === "unknown");
/**
* The signal is being fetched and has not answered yet.
*
* Worth its own state rather than folding into "no panel to show". Until it
* answers, `authSignalStatus` is null and every not-signed-in customer looks
* momentarily identical to a signed-in one so the card would assert that
* they are already signed in, for exactly as long as the request takes, and
* then replace it with a sign-in prompt. A reassurance that is wrong and then
* withdrawn is worse than saying nothing for a beat.
*/
const authSignalUndecided = canShowAdapterLogin && authSignalStatus === null;
const isLocalAdapterCaps =
adapterCaps.supportsInstructionsBundle ||
@ -924,6 +1019,25 @@ function OnboardingWizardInner({
};
}, [disabledTypes]);
/**
* A source chosen from the visible row. Read off the row rather than off
* `adapterType` alone, because a restored draft can name an adapter this step
* no longer offers a selection the customer cannot see.
*/
const sourceSelected = recommendedAdapters.some((opt) => opt.type === adapterType);
/**
* When the input canvas is open.
*
* A selected source is the ordinary reason the card is the answer to the
* tile that was just pressed, so an untouched row leaves nothing under it. But
* it opens for a pending sign-in regardless of the row, because the adapter
* needing credentials does not depend on it having a tile: a restored draft
* naming an adapter this step no longer offers still cannot hire without one,
* and hiding the panel would leave that dead end with nothing to press.
*/
const canvasOpen = sourceSelected || showAdapterLoginPanel;
// The default (or a saved) adapterType can name an adapter the server has
// since disabled — e.g. a cloud sandbox registry without claude_local. The
// grid hides it, so without this snap the wizard would silently keep an
@ -970,12 +1084,22 @@ function OnboardingWizardInner({
command.trim() ||
(COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, ""));
// Throw the cached probe away whenever the thing it probed changes. Every
// input to `buildAdapterConfig` belongs in this list, `credentialMode` and
// `apiKey` included: the Connect handler reuses a passing result instead of
// re-probing, so a dependency missing here is a hire that skips the check.
//
// That is reachable rather than theoretical. The hire runs after the probe
// inside one try/catch, so a hire that fails — a network error, a server
// error — leaves the pass sitting in state. Switch to an API key, paste one,
// press Connect again, and without these two the wizard would hire against a
// key nothing ever tested.
useEffect(() => {
if (step !== 4) return;
setAdapterEnvResult(null);
adapterEnvResultAppliedStoredLoginRef.current = false;
setAdapterEnvError(null);
}, [step, adapterType, model, command, args, url]);
}, [step, adapterType, model, command, args, url, credentialMode, apiKey]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
@ -1192,7 +1316,67 @@ function OnboardingWizardInner({
}
}
function buildAdapterConfig(): Record<string, unknown> {
/**
* Store the typed key as the customer's own user secret, and report whether it
* is in place.
*
* A user secret rather than a company one, to match the subscription half of
* this very step: signing in stores the Claude token as a user secret and
* binds a `user_secret_ref`. Two credential modes on one step that scoped
* their secrets differently would be hard to justify and easy to get wrong
* later. It also keeps the key to the person who typed it instead of exposing
* it to everyone with company secret access, and agent runs still resolve it
* through the company's responsible user.
*
* A user secret needs a definition to hang off. The Claude token's is fixed
* and server-owned; there is no such definition for API keys, so onboarding
* creates one on first use. That needs company owner or admin rights, which
* whoever just created this company in onboarding has.
*
* Returns false on failure, having set the error. Callers must treat false as
* a stop: there is deliberately no path that hands the raw key back, because
* the only thing left to do with it would be to embed it.
*/
async function storeApiKeyUserSecret(companyId: string): Promise<boolean> {
const key = apiKey.trim();
const envKey = apiKeyEnvKeyFor(adapterType);
if (apiKeySecretRef.current?.key === key) return true;
try {
const entries = await secretsApi.listMyUserSecrets(companyId);
const existing = entries.find((entry) => entry.definition.key === envKey);
const definitionId =
existing?.definition.id ??
(
await secretsApi.createUserSecretDefinition(companyId, {
key: envKey,
name: `${envKey} for onboarding`,
description: "Created while connecting a model during onboarding.",
})
).id;
// Rotate rather than create when a value is already stored, because
// creating a second value for one definition is what the server refuses.
if (existing?.secret) {
await secretsApi.rotateMyUserSecret(companyId, existing.secret.id, { value: key });
} else {
await secretsApi.createMyUserSecret(companyId, {
definitionId,
definitionKey: envKey,
value: key,
});
}
apiKeySecretRef.current = { key };
return true;
} catch (err) {
setError(
err instanceof Error
? `Could not store the API key: ${err.message}`
: "Could not store the API key.",
);
return false;
}
}
function buildAdapterConfig(bindApiKey = false): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
@ -1227,6 +1411,35 @@ function OnboardingWizardInner({
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
}
// A key typed on this step is the credential the agent is being hired with,
// so it has to reach the configuration the hire sends — and the same one the
// environment test probes, or the test would pass on a config the hire does
// not use. Only when the mode asks for it: leaving a stale reference in the
// config after switching back to a subscription is what the server rejects
// alongside the Claude OAuth binding.
//
// A reference, never the key itself. The adapter configuration is
// persisted and revisioned, so a `{ type: "plain", value }` here would leave
// a live credential at rest in every copy of it. This mirrors
// `buildFixedClaudeOAuthBinding`, which holds a reference to the stored
// Claude token for the same reason.
//
// Guarded on the caller having stored the secret, not on the key being
// present. If storing failed this stays false, and the right outcome is a
// configuration with no credential — which the hire then blocks on — rather
// than one that quietly falls back to embedding the value.
if (credentialMode === "api" && bindApiKey) {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {};
env[apiKeyEnvKeyFor(adapterType)] = {
type: "user_secret_ref",
key: apiKeyEnvKeyFor(adapterType),
version: "latest",
};
config.env = env;
}
return config;
}
@ -1552,7 +1765,15 @@ function OnboardingWizardInner({
// configuration the hire sends — a config without the binding can
// report missing authentication for a user the binding would have
// covered.
const baseAdapterConfig = buildAdapterConfig();
// Store the key before anything is built from it, so both the probe and the
// hire describe it the same way — as a reference. A failure here stops the
// hire rather than falling through to a configuration with no credential.
let apiKeyStored = false;
if (credentialMode === "api" && apiKey.trim()) {
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
if (!apiKeyStored) return;
}
const baseAdapterConfig = buildAdapterConfig(apiKeyStored);
let storedClaudeLogin: ClaudeOAuthTokenStatusResponse | null = null;
if (
adapterType === "claude_local" &&
@ -1832,13 +2053,24 @@ function OnboardingWizardInner({
>
<div
className={cn(
// my-auto, not items-center on the column: they look identical
// until a step is taller than the window, where centring by
// alignment overflows in both directions and the top cannot be
// scrolled to. Auto margins collapse to zero with no free space.
"mx-auto my-auto shrink-0",
// The arc sits in the prototype's card frame; the earlier steps
// keep the split-panel layout they were designed for. One
// element styled two ways, not two wrappers, so the step
// content below renders exactly once.
isAgentArcStep
? "w-(--sz-560px) max-w-full rounded-xl border border-border bg-card px-8 py-10 sm:px-10 sm:py-11"
// No card. The steps sit on the page ground rather than in a
// bordered, filled frame — the frame was drawing a box around
// content that is already the only thing on screen, and its
// edge competed with the tiles' own strokes. The two branches
// now differ only in measure. One element styled two ways, not
// two wrappers, so the step content below renders exactly once.
// Step 1 takes the arc's measure too. Its footer is now the
// same pair, and a pair styled identically but sitting 96px
// narrower than the next screen's makes the whole frame jump on
// Continue — which is the thing that read as "off" to begin
// with, and is more obvious once the buttons match.
isAgentArcStep || step === 1
? "w-(--sz-560px) max-w-full px-8 py-10 sm:px-10 sm:py-11"
: "w-full max-w-md px-8 py-12",
)}
>
@ -1853,31 +2085,21 @@ function OnboardingWizardInner({
a segment for it would be one the run can never fill, and the
count would visibly skip from 1 to 3. */}
{!showsAgentArcStepper && (
<div className="flex items-center gap-1.5 mb-8">
{([1, 3, 4, 5] as const).map((s) => {
const filled = step >= s;
const canJump = canJumpToOnboardingStep({
targetStep: s,
currentStep: step,
entryStep,
});
return (
<button
key={s}
type="button"
aria-label={`Step ${s}`}
aria-current={s === step ? "step" : undefined}
disabled={!canJump}
onClick={() => canJump && setStep(s as Step)}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
filled ? "bg-foreground" : "bg-muted",
canJump ? "cursor-pointer" : "cursor-default"
)}
/>
);
})}
</div>
<Stepper
step={onboardingStepPositionFor(step)}
total={ONBOARDING_WIZARD_STEPS.length}
labels={ONBOARDING_STEP_LABELS}
canJumpToStep={(target) =>
canJumpToOnboardingStep({
targetStep: ONBOARDING_WIZARD_STEPS[target - 1]!,
currentStep: step,
entryStep,
})
}
onJumpToStep={(target) =>
setStep(ONBOARDING_WIZARD_STEPS[target - 1]! as Step)
}
/>
)}
{/* The agent arc's progress strip. Numbered 13 over the wizard's
@ -1909,7 +2131,14 @@ function OnboardingWizardInner({
{/* mb-6 continues the prototype's single rhythm past this
block: it groups the hero and heading, and the step's own
controls sit a step below on the same spacing. */}
<div className="mb-6 space-y-6">
{/* The gap under the agent its name to the step's title
is tighter than the step's other rows on purpose. The name
labels the character directly above it, so the two read as
one object; at the full row rhythm the name floated between
the character and the title and belonged to neither. 24px
against the 36px used elsewhere, a little over a third
less. `mb-9` still holds the block off the step content. */}
<div className="mb-9 space-y-6">
<motion.div
initial={capsuleHeroMotion.initial}
animate={capsuleHeroMotion.animate}
@ -1919,10 +2148,19 @@ function OnboardingWizardInner({
{/* Dormant until the agent is actually hired. Review is
the first step where one exists, so that is where it
wakes the arc's payoff, not a flourish along it. */}
<PillGuy
state={step === 5 ? "alive" : "dormant"}
className="size-(--sz-72px)"
/>
{/* `relative` is load-bearing: the sleep marks anchor
to this box and travel out past its top-right
corner. */}
<div className="relative size-(--sz-72px)">
<PillGuy
state={step === 5 ? "alive" : "dormant"}
className="size-full"
/>
{/* Only while it is actually asleep. A still grey
silhouette reads as a placeholder that failed to
load rather than as something waiting its turn. */}
{step < 5 && <SleepingZs />}
</div>
<AgentPreview agentName={agentName} agentRole="" />
</motion.div>
@ -1952,7 +2190,7 @@ function OnboardingWizardInner({
{/* Step content */}
{step === 2 && onboardingPath === "grow" && (
<div className="space-y-5">
<div className="space-y-8">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Sparkles className="h-5 w-5 text-muted-foreground" />
@ -2037,18 +2275,29 @@ function OnboardingWizardInner({
</div>
)}
{/* Step 1: name the organization (both paths). One question, one
design: this mirrors the funnel's naming screen same
question, same sub, same left-aligned heading in a centered
column so a customer creating their second organization
in-app is asked exactly what their first one asked them. */}
{/* Step 1: name the organization (both paths).
Dressed as the arc steps that follow it centred heading, no
lede, and the same footer pair because a customer walks
straight from here into them, and one screen reading as a
different product is more jarring than this one no longer
matching the funnel's naming screen exactly. The question
itself is still the funnel's, so the ask has not changed.
The lede went because it said what the field already says: a
labelled "Name" under "What is the name of your organization?"
does not need a sentence explaining that it names the
organization. */}
{step === 1 && (
<div className="mx-auto w-full max-w-md space-y-6">
<div className="mx-auto w-full space-y-9">
<OnboardingHeading
center
title="What is the name of your organization?"
lede="This will be the name of your Paperclip organization — choose something your team will recognize."
/>
<div className="group">
{/* The field takes the agent step's measure rather than the
column's, so the two questions the wizard asks name the
organization, name the agent present the same target.
The heading stays full width above it, as it does there. */}
<div className="group mx-auto w-full max-w-(--sz-320px)">
<label
className={cn(
"text-xs mb-1 block transition-colors",
@ -2074,18 +2323,12 @@ function OnboardingWizardInner({
autoFocus
/>
</div>
<button
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setOnboardingPath(null); setStep(0); }}
>
Back to start
</button>
</div>
)}
{/* Step 2: Define your mission */}
{step === 2 && onboardingPath !== "grow" && (
<div className="space-y-5">
<div className="space-y-8">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
@ -2281,12 +2524,12 @@ function OnboardingWizardInner({
`general` role; a specific one can be set later, where there
is context to choose it in. */}
{step === 3 && (
<div className="mx-auto flex w-full max-w-(--sz-320px) flex-col gap-6">
<div className="mx-auto flex w-full max-w-(--sz-320px) flex-col gap-9">
<div className="flex flex-col gap-2">
<Label htmlFor="onboarding-agent-name">Name</Label>
<Input
id="onboarding-agent-name"
placeholder="e.g. Chief of staff, Designer, Ron, Clippy..."
placeholder="e.g. Chief of staff, Designer, Ron..."
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
autoFocus
@ -2297,132 +2540,130 @@ function OnboardingWizardInner({
{/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
{step === 4 && (
<div className="space-y-5">
<div className="space-y-8">
{/* The two cards are self-describing; an "Adapter type"
eyebrow above them named the mechanism rather than the
choice. */}
<div>
<div className="grid grid-cols-2 gap-2">
{recommendedAdapters.map((opt) => (
<button
key={opt.type}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
adapterType === opt.type
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
const nextType = opt.type;
setAdapterType(nextType);
if (nextType === "codex_local") {
return;
}
if (nextType === "opencode_local") {
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
return;
}
setModel("");
}}
>
{/* No "Recommended" badge: it sat on both options,
so it recommended nothing and only added the one
saturated colour on the screen. */}
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-(length:--text-nano)">
{opt.description}
</span>
</button>
))}
</div>
{/* The row is `ModelSourceTiles`, the same component the
connect-step prototype is drawn with, so the shipped step
and the design under review cannot drift apart.
<button
className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowMoreAdapters((v) => !v)}
>
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
showMoreAdapters ? "rotate-0" : "-rotate-90"
)}
/>
Advanced settings
</button>
{showMoreAdapters && (
<div className="grid grid-cols-2 gap-2 mt-2">
{moreAdapters.map((opt) => (
<button
key={opt.type}
disabled={!!opt.comingSoon}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
opt.comingSoon
? "border-border opacity-40 cursor-not-allowed"
: adapterType === opt.type
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
if (opt.comingSoon) return;
const nextType = opt.type;
setAdapterType(nextType);
if (nextType === "gemini_local" && !model) {
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
return;
}
if (nextType === "kimi_local" && !model) {
setModel(DEFAULT_KIMI_LOCAL_MODEL);
return;
}
if (nextType === "cursor" && !model) {
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
return;
}
if (nextType === "opencode_local") {
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
return;
}
setModel("");
}}
>
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-(length:--text-nano)">
{opt.comingSoon
? opt.disabledLabel ?? "Coming soon"
: opt.description}
</span>
</button>
))}
</div>
)}
</div>
{/* Shows as soon as the cheap auth signal reports no ready
credential, well before any adapter environment test
runs. Reuses the same panel the agent configuration form
shows after a test see AdapterLoginPanel in
AgentConfigForm.tsx. No "Use saved login" control: the
hire step already applies a stored login on its own. */}
{showAdapterLoginPanel && createdCompanyId && resolvedLoginEnvironmentId && (
<AdapterLoginPanel
key={`${adapterType}:${resolvedLoginEnvironmentId}`}
companyId={createdCompanyId}
adapterType={adapterType}
environmentId={resolvedLoginEnvironmentId}
onStored={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.agents.authSignal(
createdCompanyId,
adapterType,
resolvedLoginEnvironmentId,
),
});
Sources come from `recommendedAdapters`, not a list
written here. That filter is `recommended` in the display
registry, which today means Claude Code and Codex and
nothing else so the row stays two tiles because the
registry says so, and a third would appear here the day
someone marks one rather than the day someone remembers
to edit this file. */}
<ModelSourceTiles
label="Model source"
sources={recommendedAdapters.map((opt) => ({
id: opt.type,
label: opt.label,
icon: <ModelSourceMark type={opt.type} Fallback={opt.icon} />,
}))}
mode={credentialMode}
selectedId={
recommendedAdapters.some((opt) => opt.type === adapterType)
? adapterType
: null
}
onSelect={(id) => {
setAdapterType(id);
if (id === "codex_local") return;
if (id === "opencode_local") {
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
return;
}
setModel("");
}}
/>
)}
{/* The credential switch stands where the adapter
disclosure used to. That disclosure existed to reach the
adapters this step does not offer, and with the row down
to the two that are supported it was a control whose
whole contents were out of scope. The question actually
left on this step is how the two are authenticated, so
that is what the line asks.
It names the destination rather than the state, which is
what a sentence has to do where a checkbox does not
and it is only readable because the tiles' own tags,
directly above, say where you are. */}
<div className="-ml-3 mt-1">
<CredentialModeLink
mode={credentialMode}
onChange={setCredentialMode}
/>
</div>
</div>
{/* One canvas under the tiles, holding whatever the current
choice needs: a browser-code login for Claude, a
displayed-code login for Codex, or a key field for either
when the mode is keys. Four inputs, one place so the
Connect button below does not move every time the answer
changes.
Closed until a source is picked. `contentKey` is the
source and the mode together, because either one changing
means a different input, and that is what the canvas
swaps on. */}
<ConnectInputCanvas
open={canvasOpen}
contentKey={`${adapterType}:${credentialMode}`}
>
{credentialMode === "api" ? (
<ApiKeyField
envKey={apiKeyEnvKeyFor(adapterType)}
value={apiKey}
onChange={setApiKey}
/>
) : showAdapterLoginPanel &&
createdCompanyId &&
resolvedLoginEnvironmentId ? (
/* Shows as soon as the cheap auth signal reports no ready
credential, well before any adapter environment test
runs. Reuses the same panel the agent configuration
form shows after a test see AdapterLoginPanel in
AgentConfigForm.tsx. No "Use saved login" control: the
hire step already applies a stored login on its own. */
<AdapterLoginPanel
key={`${adapterType}:${resolvedLoginEnvironmentId}`}
companyId={createdCompanyId}
adapterType={adapterType}
environmentId={resolvedLoginEnvironmentId}
onStored={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.agents.authSignal(
createdCompanyId,
adapterType,
resolvedLoginEnvironmentId,
),
});
}}
/>
) : (
/* No panel to show, and the two reasons for that are not
the same news. Saying either is better than an empty
card the canvas is open because a source is selected,
and a blank one reads as something that failed to load
but they must not be conflated: telling someone with no
sandbox that they are "already signed in" on it is
false, and it hides the one thing actually blocking
them. */
<p className="text-xs text-muted-foreground">
{authSignalUndecided
? "Checking this source's credentials…"
: canShowAdapterLogin
? "This source is already signed in on the managed sandbox."
: "No managed sandbox is available to sign in against yet."}
</p>
)}
</ConnectInputCanvas>
{/* Conditional adapter fields */}
{/* No model picker. Every adapter this step offers resolves
@ -2590,37 +2831,67 @@ function OnboardingWizardInner({
</div>
)}
{isAgentArcStep && (
{/* Step 1 shares the arc's footer so the pair keeps its shape and
position from the first screen onward. Its Back is the only one
that leaves the wizard's steps rather than walking them: step 1
is where a company is named, and behind it is the path chooser,
so `canGoBackFromOnboardingStep` which bounds a run to the
steps it entered on does not decide this one. */}
{(isAgentArcStep || step === 1) && (
<FooterNav
onBack={
canGoBackFromOnboardingStep({ currentStep: step, entryStep })
? () => setStep(backStepFrom(step))
: undefined
step === 1
? () => {
setOnboardingPath(null);
setStep(0);
}
: canGoBackFromOnboardingStep({ currentStep: step, entryStep })
? () => setStep(backStepFrom(step))
: undefined
}
// The prototype's cloud flow hires on this step and calls the
// action "Create". Here the model step sits between, so this
// one advances — which is exactly the distinction the
// prototype's own local flow draws with "Next".
primaryLabel={step === 3 ? "Next" : step === 4 ? "Connect" : "Get started"}
loadingLabel={step === 4 ? "Connecting..." : "Launching..."}
primaryLabel={
step === 1
? "Continue"
: step === 3
? "Next"
: step === 4
? "Connect"
: "Get started"
}
loadingLabel={
step === 1
? "Creating..."
: step === 4
? "Connecting..."
: "Launching..."
}
loading={step === 3 ? false : loading}
primaryDisabled={
step === 3
? !agentName.trim()
: step === 4
? loading || adapterEnvLoading || missionUnresolvedForHire
: loading || launchStateIncomplete
step === 1
? !companyName.trim() || loading
: step === 3
? !agentName.trim()
: step === 4
? loading || adapterEnvLoading || missionUnresolvedForHire
: loading || launchStateIncomplete
}
onPrimary={() => {
if (step === 3) setStep(4);
if (step === 1) {
if (skipsMissionStep) void handleCreateCompany();
else setStep(2);
} else if (step === 3) setStep(4);
else if (step === 4) handleGiveHeartbeat();
else handleLaunchToDashboard();
}}
/>
)}
{/* Footer navigation */}
{!isAgentArcStep && (
{/* Footer navigation for the steps that still use the old pair. */}
{!isAgentArcStep && step !== 1 && (
<div className="flex items-center justify-between mt-8">
<div>
{canGoBackFromOnboardingStep({ currentStep: step, entryStep }) && (
@ -2636,22 +2907,6 @@ function OnboardingWizardInner({
)}
</div>
<div className="flex items-center gap-2">
{step === 1 && (
<Button
size="sm"
disabled={!companyName.trim() || loading}
onClick={() => {
if (skipsMissionStep) void handleCreateCompany();
else setStep(2);
}}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : null}
Continue
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
)}
{step === 2 && (
<Button
size="sm"

View File

@ -0,0 +1,159 @@
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "../../lib/utils";
import {
CANVAS_CONTENT_ENTER,
CANVAS_CONTENT_EXIT,
CANVAS_CONTENT_TRAVEL,
} from "./onboarding-motion";
/**
* The connect step's input surface: one card that holds whatever the current
* choice needs, rather than a different control appearing in a different place
* for each combination.
*
* There are four things it can hold a browser-code login for Claude, a
* displayed-code login for Codex, and an API key field for either and they are
* not the same shape or the same height. Giving each its own slot would move the
* Connect button every time the choice changed. One canvas that resizes keeps
* the step's furniture still and makes the card read as the answer to the tile
* above it.
*
* It is closed until a source is picked. An empty card under an untouched row of
* tiles is a box asking to be filled with nothing.
*/
/** Three lines of body text, so a short prompt and a long one open the same card. */
const MIN_CONTENT_HEIGHT = 66;
export function ConnectInputCanvas({
open,
contentKey,
children,
}: {
open: boolean;
/**
* Identity of what is inside, and what the swap animates between. The source
* and the credential mode together, because either one changing means a
* different input is needed.
*/
contentKey: string;
children: ReactNode;
}) {
if (!open) return null;
/*
No edge and no fill of its own. Everything this holds already draws its own
surface the login panel is a bordered, filled card, the key field a
bordered input so a frame here was the same treatment twice, one nested a
few pixels inside the other. The canvas is a place for the input to be, not
a thing to look at.
Which leaves the padding to the contents as well: theirs is already sized
for what they hold, and a second inset would push it off the step's measure.
Nothing animates on this wrapper, deliberately. It carried an enter/exit
three times height, then opacity and stalled every time, once leaving the
login card rendered inside a two-pixel box and once at four percent opacity
while `open` was true the whole while. The casualty each time was the OAuth
URL a customer has to click. The swap inside still animates; the container
holding it does not need to, and cannot be trusted to.
*/
return (
<div
className="mt-5 flex items-center"
style={{ minHeight: MIN_CONTENT_HEIGHT }}
>
{/*
`popLayout`, so the leaving input is taken out of flow while it animates
and the arriving one decides the card's height on its own. The default
mode would stack them and jump the card to the sum of both mid-swap.
Not `mode="wait"`: it will not mount the next child until the previous
reports its exit finished, that report never came here, and the swap
stalled into an instant change with no transition at all.
*/}
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={contentKey}
className="w-full"
initial={{ opacity: 0, y: CANVAS_CONTENT_TRAVEL }}
animate={{ opacity: 1, y: 0, transition: CANVAS_CONTENT_ENTER }}
exit={{
opacity: 0,
y: CANVAS_CONTENT_TRAVEL,
transition: CANVAS_CONTENT_EXIT,
}}
>
{children}
</motion.div>
</AnimatePresence>
</div>
);
}
/**
* The API key field, for when the credential mode is keys rather than a
* subscription.
*
* Built to the login panel's shape on purpose: same card, same padding, same
* label-left / control-right row, same 28px control height. These two are
* alternatives to each other one canvas shows one or the other, and the
* credential switch above trades between them so they should read as two
* answers to one question rather than as two different kinds of thing. Before
* this the key field was a stacked label over a full-width input with no card
* at all, and flipping the mode changed the shape of the step rather than its
* content.
*
* The variable name is the label rather than a sentence about it. Someone
* pasting a key knows which one they are holding; what they cannot know is where
* this step will put it, and the name answers that in the place it is asked
* while staying short enough to sit opposite the field the way "Sign in to the
* environment" sits opposite its button.
*/
export function ApiKeyField({
envKey,
value,
onChange,
}: {
envKey: string;
value: string;
onChange: (next: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
// Focus on mount, because the canvas only opens when this is the thing that
// was asked for. Layout effect so it happens before paint rather than as a
// visible jump after it.
useLayoutEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
<label className="flex items-center justify-between gap-3">
<span className="font-mono text-xs font-medium text-foreground">
{envKey}
</span>
<input
ref={inputRef}
type="password"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Paste your key"
// `h-7` is the login button's height, so the two states put their
// control on the same line and the card does not change depth when the
// mode is flipped.
className={cn(
"h-7 w-(--sz-220px) shrink-0 rounded-md border border-border bg-background px-2",
"font-mono text-xs outline-none placeholder:font-sans",
"focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)",
)}
/>
</label>
</div>
);
}

View File

@ -0,0 +1,152 @@
import { useState } from "react";
import { MotionConfig } from "motion/react";
import { Checkbox } from "../ui/checkbox";
import { AgentPreview } from "./AgentPreview";
import { CredentialModeLink } from "./CredentialModeLink";
import { FooterNav } from "./FooterNav";
import {
ModelSourceTiles,
type CredentialMode,
type ModelSource,
} from "./ModelSourceTiles";
import { OnboardingHeading } from "./OnboardingPrimitives";
import { PillGuy } from "./PillGuy";
import { SleepingZs } from "./SleepingZs";
import { Stepper } from "./Stepper";
/**
* A prototype of the connect step, from the PCLP-Onboarding file (nodes
* 2941:8291 and 2933:4592).
*
* A mock, not the shipped step. The wizard's real step 4 puts two adapter cards
* over an advanced-settings disclosure and probes the environment before
* hiring; none of that is wired up here. What is here is the part the design is
* actually asking a question about how the row of sources reads as you point
* at it, pick one, and flip the whole row between subscription and API
* credentials so it can be judged before any of that machinery is moved.
*
* It lives in `components/` rather than beside a story because two surfaces
* render it: the Storybook stories, and the standalone
* `connect-model-preview.html` entry that gets deployed for review. A copy in
* each would have drifted the moment one was tweaked.
*
* Nothing here reaches a backend, and it needs none of the app's providers
* every piece it composes is presentational.
*/
/**
* The two sources the step offers, matching the shipped step's own list.
*
* Claude Code and Codex are the only adapters the display registry marks
* `recommended`, and the real step builds its row from exactly that filter so
* a third tile here would be a design the wizard could never render. OpenCode
* was drawn at one point and is deliberately gone.
*/
const MODEL_SOURCES: ModelSource[] = [
{
id: "claude_local",
label: "Claude Code",
icon: <img src="/brands/claude-color.svg" alt="" className="size-full" />,
},
{
id: "codex_local",
label: "Codex",
icon: <img src="/brands/codex-color.svg" alt="" className="size-full" />,
},
];
/**
* Which control flips the credential mode. Two alternates of the same
* behaviour, kept side by side so they can be compared rather than argued
* about:
*
* `checkbox` is the Figma frames a ticked box reading "Use API keys instead",
* which shows the current state plainly and costs a row of chrome.
*
* `link` is a line of text that renames itself on press. Lighter, and it turns
* the row into a single sentence, but it can only ever name the destination
* so where you are now is left entirely to the tiles' tags.
*/
export type CredentialControl = "checkbox" | "link";
export function ConnectModelPreview({
initialSourceId = null,
initialUseApiKeys = false,
control = "checkbox",
}: {
initialSourceId?: string | null;
initialUseApiKeys?: boolean;
control?: CredentialControl;
}) {
const [selectedId, setSelectedId] = useState<string | null>(initialSourceId);
const [useApiKeys, setUseApiKeys] = useState(initialUseApiKeys);
const mode: CredentialMode = useApiKeys ? "api" : "subscription";
return (
// The arc's own convention: OS-level reduced motion neutralises the
// movement, and every piece below still arrives in its final state.
<MotionConfig reducedMotion="user">
<div className="w-(--sz-560px) max-w-full p-10">
{/* Connect is the arc's second step. `Stepper` carries its own bottom
margin, which is the gap the frame wants under the dots. */}
<Stepper step={2} />
<div className="flex flex-col items-center">
{/* `relative` is load-bearing: the sleep marks anchor to this box and
travel out past its top-right corner. */}
<div className="relative size-(--sz-72px)">
<PillGuy state="dormant" className="size-full" />
<SleepingZs />
</div>
<AgentPreview agentName="Darnold" agentRole="" />
</div>
<div className="pt-6">
<OnboardingHeading
center
title="Connect a model"
lede="Paperclip works with your existing subscription or API keys."
/>
</div>
<div className="space-y-2 pt-12">
<ModelSourceTiles
label="Model source"
sources={MODEL_SOURCES}
mode={mode}
selectedId={selectedId}
onSelect={setSelectedId}
/>
{control === "link" ? (
<CredentialModeLink
mode={mode}
onChange={(next) => setUseApiKeys(next === "api")}
/>
) : (
<label className="flex cursor-pointer items-start gap-2.5 px-3 py-2">
<Checkbox
className="mt-0.5"
checked={useApiKeys}
onCheckedChange={(checked) => setUseApiKeys(checked === true)}
/>
<span className="text-sm font-medium text-foreground">
Use API keys instead
</span>
</label>
)}
</div>
{/* The CTA has nothing to connect until a source is picked, so it stays
disabled rather than failing on press. */}
<FooterNav
onBack={() => {}}
primaryLabel="Connect"
primaryDisabled={selectedId === null}
onPrimary={() => {}}
/>
</div>
</MotionConfig>
);
}

View File

@ -0,0 +1,89 @@
import { AnimatePresence, motion } from "motion/react";
import { cn } from "../../lib/utils";
import type { CredentialMode } from "./ModelSourceTiles";
import { LINK_LABEL_FADE_IN, LINK_LABEL_FADE_OUT } from "./onboarding-motion";
/**
* The credential-mode switch as a line of text instead of a checkbox an
* alternate for the connect step, not a replacement.
*
* The label names the destination rather than the state: "Use API keys
* instead" while on the subscription, "Use subscription instead" once on API
* keys. That is what makes a link work where a checkbox does not a checkbox
* can be ticked or not and reads the same either way, whereas a bare sentence
* has to say what pressing it does. The consequence is that this control never
* shows you where you are; the tiles' tags do that, and this alternate only
* holds up because they are right above it.
*/
const LINK_LABEL: Record<CredentialMode, string> = {
subscription: "Use API keys instead",
api: "Use subscription instead",
};
const OTHER_MODE: Record<CredentialMode, CredentialMode> = {
subscription: "api",
api: "subscription",
};
export function CredentialModeLink({
mode,
onChange,
}: {
mode: CredentialMode;
onChange: (next: CredentialMode) => void;
}) {
return (
<button
type="button"
onClick={() => onChange(OTHER_MODE[mode])}
className={cn(
// A grid rather than a flow of text, so both labels can occupy one cell
// and overlap during the swap. Same padding as the checkbox row this
// stands in for, so switching between the two alternates moves nothing
// else on the step.
"group grid cursor-pointer px-3 py-2 text-left text-sm font-medium",
"rounded-md outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)",
)}
>
{/*
Both labels, kept in the layout but out of sight, so the cell is as wide
as the wider of the two and the box never resizes mid-swap. `invisible`
also takes them out of the accessibility tree, leaving the button's name
to the one real label below.
*/}
{(Object.keys(LINK_LABEL) as CredentialMode[]).map((sizerMode) => (
<span
key={sizerMode}
aria-hidden
className="invisible col-start-1 row-start-1 whitespace-nowrap"
>
{LINK_LABEL[sizerMode]}
</span>
))}
<AnimatePresence initial={false} mode="sync">
<motion.span
key={mode}
// Left-aligned in that max-width cell, so the sentence starts at the
// same x in both states and only its tail changes.
className={cn(
"col-start-1 row-start-1 justify-self-start whitespace-nowrap",
// The underline sits on the label, never on the button: the button
// is as wide as the longer sentence, so an underline there would
// run past the end of the shorter one.
"underline decoration-muted-foreground/40 underline-offset-4",
"text-muted-foreground transition-colors",
"group-hover:text-foreground group-hover:decoration-foreground/40",
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: LINK_LABEL_FADE_IN }}
exit={{ opacity: 0, transition: LINK_LABEL_FADE_OUT }}
>
{LINK_LABEL[mode]}
</motion.span>
</AnimatePresence>
</button>
);
}

View File

@ -25,14 +25,19 @@ export function FooterNav({
onPrimary: () => void;
}) {
return (
<div className="flex items-center justify-between pt-6">
<div className="flex items-center justify-between pt-9">
{onBack ? (
// has-[>svg]:pr-4 gives "Back" room from the pill's right edge,
// overriding size="sm"'s symmetric padding on that side only.
// Same size as the primary, not a tier down. Back is ghost until you
// point at it, and a shorter pill made the hover surface read as a
// different kind of control sitting slightly low in the row rather than
// the other half of a pair.
//
// The padding stays asymmetric against size="lg"'s symmetric px-4: the
// arrow needs less room on its side than the word does on its own.
<Button
variant="ghost"
size="sm"
className="rounded-full has-[>svg]:pr-4"
size="lg"
className="rounded-full has-[>svg]:pl-4 has-[>svg]:pr-5"
onClick={onBack}
disabled={loading}
>

View File

@ -0,0 +1,175 @@
import { useRef, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "../../lib/utils";
import { TAG_SWAP_ENTER, TAG_SWAP_EXIT, TAG_SWAP_TRAVEL } from "./onboarding-motion";
/**
* The connect step's row of model sources, and the tag under each one saying
* which credential the source would be reached with.
*
* Presentational only the caller owns which source is picked and which
* credential mode is in force, because both outlive this row: the mode is set
* by a checkbox that sits below it, and the selection drives the step's CTA.
*/
/** How a source gets authenticated. Every tile is in the same mode at once. */
export type CredentialMode = "subscription" | "api";
export type ModelSource = {
id: string;
label: string;
/** The brand mark, rendered into a 30px square. */
icon: ReactNode;
};
const CREDENTIAL_TAG_LABEL: Record<CredentialMode, string> = {
subscription: "Subscription",
api: "API",
};
/**
* The credential tag, swapping in a fixed-height slot.
*
* The slot has to hold its height whatever is in it: the tag is the last line
* of the tile, and a label that measured itself would resize the tile mid-swap
* and nudge the two beside it. `overflow-hidden` is doing real work too it is
* what makes the outgoing label fall out of frame rather than slide past the
* tile's padding and over the row below.
*/
export function CredentialTag({ mode }: { mode: CredentialMode }) {
return (
<span className="relative flex h-4 w-full items-center justify-center overflow-hidden text-(length:--text-micro) text-muted-foreground">
<AnimatePresence initial={false} mode="sync">
<motion.span
key={mode}
className="absolute inset-0 flex items-center justify-center whitespace-nowrap"
initial={{ opacity: 0, y: TAG_SWAP_TRAVEL }}
animate={{ opacity: 1, y: 0, transition: TAG_SWAP_ENTER }}
exit={{ opacity: 0, y: TAG_SWAP_TRAVEL, transition: TAG_SWAP_EXIT }}
>
{CREDENTIAL_TAG_LABEL[mode]}
</motion.span>
</AnimatePresence>
</span>
);
}
function ModelSourceTile({
source,
mode,
selected,
onSelect,
buttonRef,
}: {
source: ModelSource;
mode: CredentialMode;
selected: boolean;
onSelect: () => void;
buttonRef: (node: HTMLButtonElement | null) => void;
}) {
return (
<button
ref={buttonRef}
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className={cn(
"flex min-w-0 flex-1 cursor-pointer flex-col items-center gap-1.5 self-stretch rounded-md border p-3",
"transition-(--tp-border-color-background-color) duration-(--motion-duration-fast) ease-(--motion-ease-standard)",
// Focus is a ring, never a border. The stroke has exactly one job here
// and lending it to focus as well would mean tabbing across the row
// looked like picking every tile in turn.
"outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)",
// Hover brings the surface up to the same half-strength ground the
// selected tile already sits on, and stops there. Pointing at a tile
// should say "this one is live", not "this one is chosen" — so the
// bright stroke stays reserved for the choice, and the only thing
// separating hover from selection is the border.
selected ? "border-foreground bg-accent/50" : "border-border hover:bg-accent/50",
)}
>
<span className="flex size-(--sz-30px) shrink-0 items-center justify-center">
{source.icon}
</span>
{/*
One step up the named ladder each the source name from text-xs (12px)
to --text-compact (13px), the tag under it from --text-nano (10px) to
--text-micro (11px), keeping the two a step apart. Both use the
font-size-only token form, so the line box comes from the tile's own
rhythm rather than the Tailwind scale's paired line-height.
*/}
<span className="text-(length:--text-compact) font-medium text-foreground">
{source.label}
</span>
<CredentialTag mode={mode} />
</button>
);
}
export function ModelSourceTiles({
sources,
mode,
selectedId,
onSelect,
label,
}: {
sources: ModelSource[];
mode: CredentialMode;
/** `null` before anything has been picked — the step opens this way. */
selectedId: string | null;
onSelect: (id: string) => void;
label: string;
}) {
const tiles = useRef(new Map<string, HTMLButtonElement>());
/**
* Arrow keys move the selection and the focus together, which is what a
* radio group is expected to do without it the role would be announced and
* then not behave, which is worse than plain buttons. Selection wraps at both
* ends; three tiles is short enough that stopping at the edges just reads as
* the key having failed.
*/
const moveSelection = (delta: number) => {
if (sources.length === 0) return;
const current = sources.findIndex((source) => source.id === selectedId);
// Nothing picked yet: either arrow enters the row from the near end.
const from = current === -1 ? (delta > 0 ? -1 : 0) : current;
const next = (from + delta + sources.length) % sources.length;
const target = sources[next]!;
onSelect(target.id);
tiles.current.get(target.id)?.focus();
};
return (
<div
role="radiogroup"
aria-label={label}
className="flex items-start gap-3"
onKeyDown={(event) => {
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
moveSelection(1);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
moveSelection(-1);
}
}}
>
{sources.map((source) => (
<ModelSourceTile
key={source.id}
source={source}
mode={mode}
selected={source.id === selectedId}
onSelect={() => onSelect(source.id)}
buttonRef={(node) => {
if (node) tiles.current.set(source.id, node);
else tiles.current.delete(source.id);
}}
/>
))}
</div>
);
}

View File

@ -0,0 +1,177 @@
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "../../lib/utils";
/**
* Sleep marks drifting off the dormant agent.
*
* The capsule is grey and closed-eyed for the whole of the connect step, which
* is accurate nothing has been hired yet but a still silhouette reads as a
* placeholder that failed to load rather than as something waiting. Three small
* z's rising off its shoulder say "asleep, not broken" without adding a second
* thing to look at.
*
* Decorative and announced to nobody: the state it depicts is already carried
* by the step's own copy.
*/
/**
* The glyphs, smallest first. Each rises further and ends larger than the one
* below it, so the three together read as one plume with depth rather than as
* three identical marks on different timers the "zzZZ" shape of the thing
* written down.
*/
const Z_TIERS = [
{ glyph: "z", sizeClass: "text-(length:--text-nano)", scaleTo: 0.95, reach: 1 },
{ glyph: "z", sizeClass: "text-xs", scaleTo: 1.1, reach: 1.25 },
{ glyph: "Z", sizeClass: "text-sm", scaleTo: 1.25, reach: 1.5 },
] as const;
/**
* Applied to both ends of every glyph's scale, so the marks read larger without
* the plume changing shape a bump to the tiers' own `scaleTo` values alone
* would have grown the three by different amounts and flattened the depth
* between them.
*/
const Z_SCALE = 1.1;
/**
* Where a glyph is born, measured down from the anchor at the dome's crown.
*
* Low enough that the marks read as rising off the head rather than hovering
* above it, but back off the silhouette: further down, the first frames of each
* glyph landed on the dome's own grey and the fade-in was lost against it.
*/
const ORIGIN_DROP = 17;
const Z_SCALE_FROM = 0.55 * Z_SCALE;
type ZTier = (typeof Z_TIERS)[number];
type ZFlight = {
launchX: number;
launchY: number;
driftX: number;
driftY: number;
rotate: number;
duration: number;
delay: number;
};
function randomBetween(min: number, max: number) {
return min + Math.random() * (max - min);
}
/**
* A fresh flight for one glyph.
*
* Re-rolled every cycle rather than fixed at mount. Three fixed loops of
* different lengths do drift apart, but they still repeat exactly, and at this
* size the eye picks the period up within a few passes which is the one thing
* an idle animation must not do.
*/
function nextFlight(tier: ZTier, index: number, first: boolean): ZFlight {
// Each mark leaves from a slightly different point rather than all three from
// one. Without this the tiers launch stacked and the first moment of a cycle
// is a smudge of overlapping glyphs instead of a plume.
const launchX = randomBetween(-4, 4);
// Every glyph starts `ORIGIN_DROP` below the anchor and climbs from there.
// The drift is measured off the launch point rather than the anchor, so
// moving the origin slides the whole plume without shortening its travel.
const launchY = ORIGIN_DROP + randomBetween(-3, 3);
return {
launchX,
launchY,
driftX: launchX + randomBetween(9, 18) * tier.reach,
driftY: launchY - randomBetween(20, 30) * tier.reach,
rotate: randomBetween(-14, 16),
duration: randomBetween(1.9, 2.6),
// A gap between cycles, so the plume puffs rather than streams. Kept short
// enough that all three are never idle together for long: an animation
// whose whole point is "still running, just asleep" cannot afford stretches
// where there is nothing on screen at all. The first delay is staggered by
// tier so they do not launch as one on the step's first frame.
delay: first ? index * 0.4 : randomBetween(0.3, 1.1),
};
}
function SleepyZ({ tier, index }: { tier: ZTier; index: number }) {
// `cycle` is a remount key, not a counter anyone reads: changing it replaces
// the span so the next flight starts from `initial` again. Re-running
// `animate` alone would tween from wherever the last one ended, and the glyph
// would wander off instead of restarting at the shoulder.
const [cycle, setCycle] = useState(0);
const [flight, setFlight] = useState(() => nextFlight(tier, index, true));
return (
<motion.span
key={cycle}
className={cn(
"absolute font-semibold text-muted-foreground select-none",
tier.sizeClass,
)}
initial={{
opacity: 0,
x: flight.launchX,
y: flight.launchY,
scale: Z_SCALE_FROM,
rotate: 0,
}}
animate={{
opacity: [0, 1, 1, 0],
x: flight.driftX,
y: flight.driftY,
scale: tier.scaleTo * Z_SCALE,
rotate: flight.rotate,
}}
transition={{
duration: flight.duration,
delay: flight.delay,
// Ease-out sine: the mark leaves the shoulder with a little pace and
// slows as it goes, the way something buoyant does.
ease: [0.39, 0.575, 0.565, 1],
// Fades in over the first fifth and out over the last third, holding
// solid in between. Without the hold the glyph is never fully legible.
opacity: {
duration: flight.duration,
delay: flight.delay,
times: [0, 0.2, 0.66, 1],
},
}}
onAnimationComplete={() => {
setFlight(nextFlight(tier, index, false));
setCycle((previous) => previous + 1);
}}
>
{tier.glyph}
</motion.span>
);
}
/**
* Positioned absolutely over the caller's capsule, which must be `relative`.
* The marks are anchored to the dome's upper-right shoulder and travel out
* past the box, so nothing between here and the step's own frame may clip.
*
* Rendered as nothing at all when the OS asks for reduced motion. This is the
* one animation on the step with no end the usual token-level treatment
* shortens durations, which for an endless loop just means it repeats faster.
*/
export function SleepingZs({ className }: { className?: string }) {
const reducedMotion = useReducedMotion();
if (reducedMotion) return null;
return (
<span
aria-hidden
className={cn("pointer-events-none absolute inset-0", className)}
>
<span className="absolute left-3/4 top-1/4">
{Z_TIERS.map((tier, index) => (
<SleepyZ key={tier.glyph + String(index)} tier={tier} index={index} />
))}
</span>
</span>
);
}

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { AGENT_ARC_TOTAL_STEPS, agentArcStepFor } from "./Stepper";
import {
AGENT_ARC_TOTAL_STEPS,
ONBOARDING_WIZARD_STEPS,
agentArcStepFor,
onboardingStepPositionFor,
} from "./Stepper";
describe("agentArcStepFor", () => {
it("numbers the arc from the agent step, not from the wizard's first step", () => {
@ -30,3 +35,35 @@ describe("agentArcStepFor", () => {
}
});
});
describe("onboardingStepPositionFor", () => {
it("counts the full walk's own steps, not the wizard's", () => {
// The strip draws steps 1, 3, 4, 5 — four segments over a wizard that
// numbers to five.
expect(onboardingStepPositionFor(1)).toBe(1);
expect(onboardingStepPositionFor(3)).toBe(2);
expect(onboardingStepPositionFor(4)).toBe(3);
expect(onboardingStepPositionFor(5)).toBe(4);
});
it("holds the last completed segment on a step it does not draw", () => {
// The mission step is passed through on the "grow" path but has no segment.
// Counting keeps the strip on step 1's segment; an index lookup would find
// nothing and report no progress at all from a screen the customer reached
// by making progress.
expect(onboardingStepPositionFor(2)).toBe(1);
});
it("reports nothing before the walk starts", () => {
// The front door is not part of the count.
expect(onboardingStepPositionFor(0)).toBe(0);
});
it("never counts past the segments it advertises", () => {
for (const wizardStep of [-1, 0, 1, 2, 3, 4, 5, 6, 99]) {
const position = onboardingStepPositionFor(wizardStep);
expect(position).toBeGreaterThanOrEqual(0);
expect(position).toBeLessThanOrEqual(ONBOARDING_WIZARD_STEPS.length);
}
});
});

View File

@ -11,7 +11,7 @@ export const AGENT_ARC_TOTAL_STEPS = 3;
* What each segment goes to. These are the labels assistive tech reads, in
* place of a bare number: the wizard has its own step numbering, and two
* controls both announcing "Step 1" while meaning different steps is worse
* than no number at all. The visible "Step N of 3" line carries the count.
* than no number at all. The strip's own "Step N of 3" line carries the count.
*/
export const AGENT_ARC_STEP_LABELS = [
"Create your first agent",
@ -22,6 +22,38 @@ export const AGENT_ARC_STEP_LABELS = [
/** Wizard step numbers that make up the arc, in order. */
export const AGENT_ARC_WIZARD_STEPS = [3, 4, 5] as const;
/**
* The full walk, for a run that started at the front door rather than partway
* into the arc.
*
* Step 2 is absent: onboarding no longer asks for the mission, so a segment for
* it would be one the run can never fill. The run still passes *through* step 2
* on the "grow" path, which is why position is counted rather than looked up
* see `onboardingStepPositionFor`.
*/
export const ONBOARDING_WIZARD_STEPS = [1, 3, 4, 5] as const;
/** Destinations for the full walk, in the same order. */
export const ONBOARDING_STEP_LABELS = [
"Name your organization",
"Create your first agent",
"Connect a model",
"Review",
] as const;
/**
* Position in the full walk: how many of its steps are at or behind `step`.
*
* Counted rather than indexed because the wizard visits steps the strip does
* not draw. On the mission step there is no segment to be "on", and an index
* lookup would return nothing and light none of them reporting no progress
* from a screen the customer reached by making progress. Counting keeps the
* strip on the last segment actually completed.
*/
export function onboardingStepPositionFor(step: number): number {
return ONBOARDING_WIZARD_STEPS.filter((entry) => entry <= step).length;
}
/**
* Map a wizard step onto its position in the arc, or `null` when the step is
* outside it.
@ -40,7 +72,14 @@ export function agentArcStepFor(wizardStep: number): number | null {
}
/**
* Segmented progress strip with a "Step N of M" label.
* Segmented progress strip: three dots, centred over the step's own centred
* hero and heading.
*
* The "Step N of M" count is announced but not drawn. Three dots at this size
* are read in a glance there is no counting to help with so the line was
* spending a whole row, and the only left-aligned element on an otherwise
* centred step, to restate what the dots already say. Assistive tech has no
* glance, so the sentence stays in the accessibility tree.
*
* Segments double as the way back to a step already completed, which is the
* affordance the wizard's full-length bar provides outside the arc. A segment
@ -52,41 +91,49 @@ export function agentArcStepFor(wizardStep: number): number | null {
export function Stepper({
step,
total = AGENT_ARC_TOTAL_STEPS,
labels = AGENT_ARC_STEP_LABELS,
canJumpToStep,
onJumpToStep,
}: {
step: number;
total?: number;
/**
* What each segment goes to. Defaults to the arc's three; the full walk from
* the front door passes its own four, since the same strip serves both and a
* segment announcing "Create your first agent" on the organization step would
* be worse than a bare number.
*/
labels?: readonly string[];
canJumpToStep?: (target: number) => boolean;
onJumpToStep?: (target: number) => void;
}) {
return (
<div className="mb-7 flex flex-col items-start gap-3.5">
<div className="flex items-center gap-2">
{Array.from({ length: total }, (_, index) => index + 1).map((segment) => {
const jumpable = Boolean(canJumpToStep?.(segment) && onJumpToStep);
return (
<button
key={segment}
type="button"
aria-label={AGENT_ARC_STEP_LABELS[segment - 1] ?? `Step ${segment}`}
aria-current={segment === step ? "step" : undefined}
disabled={!jumpable}
onClick={() => jumpable && onJumpToStep?.(segment)}
className={cn(
// Dots, not bars: three of them, left-aligned, keeping the bar
// strip's gap so the rhythm is unchanged. A full-width bar implied
// a continuous quantity — how much of the arc is done — which three
// discrete steps do not have.
"size-(--sz-3px) shrink-0 rounded-full transition-colors",
segment <= step ? "bg-foreground" : "bg-border",
jumpable ? "cursor-pointer" : "cursor-default",
)}
/>
);
})}
</div>
<span className="text-(length:--text-micro) font-medium uppercase tracking-widest text-muted-foreground">
<div className="mb-11 flex items-center justify-center gap-2">
{Array.from({ length: total }, (_, index) => index + 1).map((segment) => {
const jumpable = Boolean(canJumpToStep?.(segment) && onJumpToStep);
return (
<button
key={segment}
type="button"
aria-label={labels[segment - 1] ?? `Step ${segment}`}
aria-current={segment === step ? "step" : undefined}
disabled={!jumpable}
onClick={() => jumpable && onJumpToStep?.(segment)}
className={cn(
// Dots, not bars: three of them, keeping the bar strip's gap so
// the rhythm is unchanged. A full-width bar implied a continuous
// quantity — how much of the arc is done — which three discrete
// steps do not have. At 6px they carry the row on their own now
// that no label sits under them.
"size-1.5 shrink-0 rounded-full transition-colors",
segment <= step ? "bg-foreground" : "bg-border",
jumpable ? "cursor-pointer" : "cursor-default",
)}
/>
);
})}
{/* Out of flow, so it neither takes a row nor picks up the gap. */}
<span className="sr-only">
Step {step} of {total}
</span>
</div>

View File

@ -24,7 +24,11 @@ export const CAPSULE_ENTER_DURATION = 1.0;
export const capsuleMotion = {
initial: { opacity: 0, scale: 0.5 },
animate: { opacity: 1, scale: 1 },
transition: { type: "spring" as const, duration: CAPSULE_ENTER_DURATION, bounce: 0.4 },
transition: {
type: "spring" as const,
duration: CAPSULE_ENTER_DURATION,
bounce: 0.4,
},
};
/** The name/role reveal: the label fade is staggered by 25% of this. */
@ -52,3 +56,97 @@ export const capsuleHeroMotion = {
opacity: { duration: 0.55, ease: STEP_EASE },
},
};
/**
* The credential tag's swap between "Subscription" and "API" on the connect
* step's source tiles.
*
* Both labels share one clipped slot and cross inside it: the outgoing one
* always falls out of frame while the incoming one rises into place. Fixing the
* direction is the point deriving it from which way the toggle moved would
* make one control produce two different animations, and at 10px the tag is far
* too small for that to read as anything but a flicker.
*
* The exit stays 80ms shorter than the enter so the slot has mostly cleared by
* the time the arriving label reaches the middle of it, rather than the two
* words being legible on top of each other. Both moved together when the swap
* was lengthened, which is what keeps that relationship: stretching only the
* enter would have opened the gap instead, and the swap would read as one label
* leaving and a separate one arriving.
*
* Eased in and out the house material curve, mirroring
* `--motion-ease-standard` rather than the arc's expo-out. Expo-out leaves at
* full speed from the first frame, which suits something arriving from
* offscreen; over 7px it just looked like the label snapped and then settled.
* Easing into the movement gives the swap a beginning.
*/
export const TAG_SWAP_TRAVEL = 7;
export const TAG_SWAP_EASE = [0.4, 0, 0.2, 1] as const;
export const TAG_SWAP_ENTER = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
export const TAG_SWAP_EXIT = { duration: 0.26, ease: TAG_SWAP_EASE } as const;
/**
* The credential-mode link's own label swap, when that control is a line of
* text rather than a checkbox.
*
* A plain crossfade, with no travel deliberately unlike the tag it triggers.
* The tag slides because it is being replaced inside a slot it shares with the
* label before it; the link is not replaced, it is one control renaming itself,
* and giving it the same movement would read as a second thing changing rather
* than the cause of the first.
*
* The old label leaves quickly and the new one starts once it is nearly gone,
* so the two are never both readable two near-identical sentences at half
* opacity are unreadable in a way two single words are not. Even with the
* stagger it settles just inside the tag swap, so the sentence and the tags
* finish together.
*/
export const LINK_LABEL_FADE_OUT = {
duration: 0.12,
ease: TAG_SWAP_EASE,
} as const;
export const LINK_LABEL_FADE_IN = {
duration: 0.22,
delay: 0.08,
ease: TAG_SWAP_EASE,
} as const;
/**
* The connect step's input canvas: the card that opens under the tiles once a
* source is picked, and re-fills itself when the choice changes.
*
* Everything here is the tag swap's vocabulary, reused deliberately. The canvas
* is downstream of that control picking a source or flipping the credential
* mode is what fills it so a second easing or a second rhythm would read as a
* separate thing reacting rather than the same gesture continuing.
*
* The canvas container itself does not animate at all. It carried an open/close
* three times height, then opacity and stalled every time, once leaving the
* login card rendered inside a two-pixel box. The content swap below is where
* the motion lives, and it is enough.
*/
export const CANVAS_EASE = TAG_SWAP_EASE;
/**
* The content swap inside the canvas, when the source or the credential mode
* changes while it is already open.
*
* Shorter than the canvas opening, and with the same enter/exit asymmetry as the
* tag: the outgoing input is mostly gone before the incoming one arrives, so two
* different forms are never legible on top of each other.
*
* The swap itself is the tag's, not a variation on it: one input falls out of
* the card while the next rises into place, on the same travel and the same
* curve. Flipping the credential mode moves the tag and re-fills the canvas in
* one gesture, and giving the two ends of that gesture different motion would
* make them read as separate events.
*
* There is no spinner and no hold. An earlier version had both, on the reasoning
* that the panels behind the canvas fetch but they are components, available
* the moment the choice changes, and the 400ms floor needed to make a spinner
* legible was time added to a swap that had nothing to wait for. A spinner
* standing in for no work is a slower screen that also says something untrue.
*/
export const CANVAS_CONTENT_ENTER = TAG_SWAP_ENTER;
export const CANVAS_CONTENT_EXIT = TAG_SWAP_EXIT;
export const CANVAS_CONTENT_TRAVEL = TAG_SWAP_TRAVEL;

View File

@ -0,0 +1,86 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import {
ConnectModelPreview,
type CredentialControl,
} from "./components/onboarding/ConnectModelPreview";
import "./index.css";
/**
* Harness for the standalone `connect-model-preview.html` entry the build
* that gets deployed so the connect-step mock can be reviewed from a link
* rather than a checkout.
*
* Deliberately bare. `ConnectModelPreview` composes only presentational pieces
* and never reaches a backend, so there is no provider stack, no query client
* and no router here; adding them would mean the deployed page was exercising
* different code from the Storybook one.
*
* `dark` is set on <html> in the entry document rather than mounted through
* ThemeProvider, for the same reason: the design is dark and the class variant
* is all the tokens need.
*/
/**
* `?state=` picks which frame the page opens on, mirroring the `?step=`
* convention the onboarding-flow preview uses. Everything stays clickable
* afterwards the parameter chooses a starting point, not a locked state so
* a reviewer sent straight to one frame can still reach the others.
*/
const STATES = {
default: {},
subscription: { initialSourceId: "claude_local" },
api: { initialSourceId: "claude_local", initialUseApiKeys: true },
} as const;
type StateName = keyof typeof STATES;
function isStateName(value: string | null): value is StateName {
return value !== null && value in STATES;
}
/**
* Which mode switch the page opens with. Orthogonal to `?state=`, so either
* control can be opened on any of the frames.
*
* The text link is the default because it is the direction that was chosen; the
* Figma checkbox stays reachable at `?control=checkbox` for comparison. Sharing
* a bare link and landing on the option nobody picked is a worse failure than
* having to type a parameter to see the runner-up.
*/
const DEFAULT_CONTROL: CredentialControl = "link";
function isControl(value: string | null): value is CredentialControl {
return value === "checkbox" || value === "link";
}
const params = new URLSearchParams(window.location.search);
const requested = params.get("state");
const control = params.get("control");
createRoot(document.getElementById("root")!).render(
<StrictMode>
{/*
Centred against the viewport, not against whatever the page happens to be
tall. `min-h-dvh` measures the viewport itself a percentage min-height
needs an ancestor with a definite height to resolve against, and this one
has none, so it silently resolved to nothing and the step sat at the top.
The vertical centring is `my-auto` on the child rather than `items-center`
on the row. They look identical until the step is taller than the window:
align-items overflows a centred item equally in both directions and the
top half becomes unreachable, since scrolling cannot reach above the
container's start. Auto margins collapse to zero when there is no free
space, so a short window falls back to top-aligned and scrolls.
*/}
<div className="flex min-h-dvh justify-center">
<div className="my-auto">
<ConnectModelPreview
{...STATES[isStateName(requested) ? requested : "default"]}
control={isControl(control) ? control : DEFAULT_CONTROL}
/>
</div>
</div>
</StrictMode>,
);

View File

@ -2266,6 +2266,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--sz-44rem: 44rem;
--sz-36rem: 36rem;
--sz-neg-1_25rem: -1.25rem;
--sz-85pct: 85%;
--sz-30pct: 30%;
--sz-24pct: 24%;
--sz-18pct: 18%;

View File

@ -232,8 +232,8 @@ async function renderNewAgent() {
// start the login, and let the panel reach the server `stored` state.
async function completeClaudeLogin(container: HTMLElement) {
await clickByText(container, "Test Agent");
await flushUntil(() => Boolean(findButton(container, "Log in")));
await clickByText(container, "Log in");
await flushUntil(() => Boolean(findButton(container, "Sign in")));
await clickByText(container, "Sign in");
await flushUntil(() => (container.textContent ?? "").includes("Authenticated"));
}
@ -328,12 +328,12 @@ describe("NewAgent Claude subscription login", () => {
roots.push(result.root);
// Before the test the page shows no login affordance.
expect(findButton(result.container, "Log in")).toBeFalsy();
expect(findButton(result.container, "Sign in")).toBeFalsy();
await clickByText(result.container, "Test Agent");
await flushUntil(() => Boolean(findButton(result.container, "Log in")));
await flushUntil(() => Boolean(findButton(result.container, "Sign in")));
const loginButton = findButton(result.container, "Log in");
const loginButton = findButton(result.container, "Sign in");
const createButton = findButton(result.container, "Create agent");
expect(loginButton).toBeTruthy();
expect(createButton).toBeTruthy();
@ -401,8 +401,8 @@ describe("NewAgent Claude subscription login", () => {
await clickByText(result.container, "Test Agent");
// The panel shows the replace action only after it reads the stored-token
// status, so the button label proves the panel captured the version.
await flushUntil(() => Boolean(findButton(result.container, "Log in to replace")));
await clickByText(result.container, "Log in to replace");
await flushUntil(() => Boolean(findButton(result.container, "Sign in to replace")));
await clickByText(result.container, "Sign in to replace");
await flushUntil(() => mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length > 0);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith("company-1", {

View File

@ -7,6 +7,14 @@ import {
} from "@paperclipai/shared";
import { MemoryRouter } from "@/lib/router";
import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard";
import { STORYBOOK_COMPANY_ID } from "../fixtures/onboardingDraft";
import {
STORYBOOK_SANDBOX_ENVIRONMENT_ID,
storybookAuthSignal,
storybookEnvironmentCapabilities,
storybookEnvironmentTest,
storybookEnvironments,
} from "../fixtures/onboardingEnvironment";
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
import { CompanyProvider } from "@/context/CompanyContext";
import { DialogProvider } from "@/context/DialogContext";
@ -22,6 +30,7 @@ import {
storybookAuthSession,
storybookCompanies,
storybookDashboardSummary,
storybookHiredAgent,
storybookIssues,
storybookLiveRuns,
storybookProjects,
@ -137,6 +146,10 @@ function installStorybookApiFixtures() {
return Response.json({
enableIsolatedWorkspaces: true,
autoRestartDevServerWhenIdle: false,
// The cloud-tenant shape, and what the onboarding connect step resolves
// its login environment through: without it the step looks for a local
// default and never finds the managed sandbox.
enableManagedSandboxOnly: true,
});
}
@ -144,17 +157,192 @@ function installStorybookApiFixtures() {
return Response.json({});
}
// The onboarding wizard's connect step reads these. An empty environment
// list is the cloud-tenant shape — agents run in a managed sandbox rather
// than a configured environment — and it is also the state that produces
// the "no managed sandbox environment is available" notice, which is worth
// being able to look at rather than only meeting it on a live stack.
// The connect step's provider sign-in is gated on a *sandbox* environment
// resolving, its provider supporting a login PTY, and the auth signal coming
// back absent. These three answers decide whether that panel renders at all,
// so a story picks them through `onboardingFixtureState` rather than getting
// one hard-coded shape — an earlier version returned an empty environment
// list here and made the panel invisible everywhere.
if (/^\/api\/companies\/[^/]+\/environments$/.test(url.pathname)) {
return Response.json(storybookEnvironments());
}
if (
/^\/api\/companies\/[^/]+\/environments\/capabilities$/.test(url.pathname)
) {
return Response.json(storybookEnvironmentCapabilities());
}
if (
/^\/api\/companies\/[^/]+\/adapters\/[^/]+\/auth-signal/.test(
url.pathname,
)
) {
return Response.json(storybookAuthSignal());
}
if (
/^\/api\/companies\/[^/]+\/adapters\/[^/]+\/models$/.test(url.pathname)
) {
return Response.json([]);
}
if (/^\/api\/companies\/[^/]+\/adapters\/[^/]+\/models$/.test(url.pathname)) {
return Response.json([]);
// Codex's login, which is a different flow on different routes.
//
// Claude signs in through the setup-token routes below; every other adapter
// uses these generic per-adapter ones. Only the Claude half was stubbed at
// first, so pressing Sign in on the Codex tile fell through to the dev
// server and came back 404 — which reads as a broken product rather than as
// a missing fixture, and the two are not distinguishable from the panel.
//
// Its panel mode is `displayed_code`, not Claude's `submitted_browser_code`:
// the server shows a URL *and* a code to type into it, and nothing is typed
// back here. So this is a genuinely different card, and the canvas holding it
// has to size to it too.
const adapterLoginMatch = url.pathname.match(
/^\/api\/companies\/[^/]+\/adapters\/([^/]+)\/login-sessions(?:\/([^/]+))?(\/cancel)?$/,
);
if (adapterLoginMatch) {
const session = {
sessionId: "adapter-login-storybook",
environmentId: STORYBOOK_SANDBOX_ENVIRONMENT_ID,
// `waiting_for_user` is the state this panel is worth looking at in: the
// session is live and the customer is being asked for something.
status: "waiting_for_user",
expiresAt: null,
failure: null,
};
if (adapterLoginMatch[3]) return Response.json({ ...session, status: "cancelled" });
// The prompt rides the owner read of the session rather than a route of
// its own — the shape that differs from Claude's, where it is guarded
// separately. Returning it only on the read with a session id keeps that
// distinction rather than flattening the two flows into one.
if (adapterLoginMatch[2]) {
return Response.json({
...session,
prompt: {
url: "https://auth.openai.com/device",
code: "STORY-BOOK",
},
});
}
return Response.json(session);
}
// Claude's setup-token login, enough of it to watch the panel expand.
//
// The point is not the login — it is what the panel does to the card around
// it. Starting a login turns a single row into a row plus an authorization
// URL plus a code field, and the onboarding canvas that holds it animates
// its own height and clips its overflow. A canvas that measured itself once
// would cut that expansion off, and nothing short of driving the flow would
// show it.
if (
/^\/api\/companies\/[^/]+\/setup-token-login-sessions$/.test(url.pathname)
) {
return Response.json({
sessionId: "setup-token-storybook",
environmentId: STORYBOOK_SANDBOX_ENVIRONMENT_ID,
status: "awaiting_browser_code",
expiresAt: null,
failure: null,
});
}
if (
/^\/api\/companies\/[^/]+\/setup-token-login-sessions\/[^/]+$/.test(
url.pathname,
)
) {
return Response.json({
sessionId: "setup-token-storybook",
environmentId: STORYBOOK_SANDBOX_ENVIRONMENT_ID,
status: "awaiting_browser_code",
expiresAt: null,
failure: null,
});
}
// The authorization URL is its own route, and deliberately so: the status
// read above is public and carries no secret, while the URL is an owner-only
// read. The panel polls this one separately and stays on "Preparing the
// login…" until it answers — so a fixture without it looks like a hung login
// rather than a missing route, which is exactly how it was misread once.
if (
/^\/api\/companies\/[^/]+\/setup-token-login-sessions\/[^/]+\/prompt$/.test(
url.pathname,
)
) {
return Response.json({
authorizationUrl:
"https://claude.ai/oauth/authorize?client_id=storybook&response_type=code&state=storybook",
transportAdvisory: null,
});
}
// Submitting the browser code. The panel hands the pasted code here and then
// completes; both are stubbed so the last stage of the flow — the one where
// the card is at its tallest — can actually be reached.
if (
/^\/api\/companies\/[^/]+\/setup-token-login-sessions\/[^/]+\/code$/.test(
url.pathname,
)
) {
return Response.json({
sessionId: "setup-token-storybook",
environmentId: STORYBOOK_SANDBOX_ENVIRONMENT_ID,
status: "awaiting_completion",
expiresAt: null,
failure: null,
transportAdvisory: null,
});
}
if (
/^\/api\/companies\/[^/]+\/claude-oauth-token-status$/.test(url.pathname)
) {
return new Response(null, { status: 404 });
}
// The hire, and the three calls either side of it.
//
// These exist so the review step can be reached the way a customer reaches
// it — by pressing Connect — rather than by seeding a draft that claims the
// hire already happened. The difference is not pedantry: the wizard only
// offers Back on a step it walked *forward* into, so a story that starts on
// the review step renders it without the control it is supposed to have.
//
// The environment test, which is the hire's gate. It answers from the story's
// auth state rather than always passing — see `storybookEnvironmentTest`.
const testEnvMatch = url.pathname.match(
/^\/api\/companies\/[^/]+\/adapters\/([^/]+)\/test-environment$/,
);
if (testEnvMatch) {
return Response.json(storybookEnvironmentTest(testEnvMatch[1]));
}
if (/^\/api\/companies\/[^/]+\/agent-hires$/.test(url.pathname)) {
// `approval: null` on purpose. A hire that returns one sends the wizard
// through the approvals API before it advances, and this story is about
// the step it lands on rather than the path it took.
return Response.json({ agent: storybookHiredAgent, approval: null });
}
const instructionsBundleMatch = url.pathname.match(
/^\/api\/agents\/([^/]+)\/instructions-bundle(\/file)?$/,
);
if (instructionsBundleMatch) {
// The wizard seeds the lead's instructions here and swallows a failure —
// so an unstubbed route costs nothing but a console warning on every run,
// which is the kind of noise that trains people to ignore the console.
if (instructionsBundleMatch[2]) {
return Response.json({ path: "AGENTS.md", content: "" });
}
return Response.json({
agentId: instructionsBundleMatch[1],
companyId: STORYBOOK_COMPANY_ID,
mode: "managed",
rootPath: null,
managedRootPath: `/managed/agents/${instructionsBundleMatch[1]}`,
entryFile: "AGENTS.md",
resolvedEntryPath: `/managed/agents/${instructionsBundleMatch[1]}/AGENTS.md`,
editable: true,
warnings: [],
});
}
if (
@ -232,6 +420,16 @@ function installStorybookApiFixtures() {
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsModelProfiles: true,
// `useAdapterCapabilities` prefers this listing over its own static
// defaults, so an omission here is not a smaller fixture — it is a
// capability the adapter loses. Without `login` the onboarding
// connect step's provider sign-in silently never renders, which is
// indistinguishable from it having been removed. Mirrors
// `KNOWN_DEFAULTS` in `use-adapter-capabilities.ts`.
login: {
panelMode: "submitted_browser_code",
timeoutPolicy: "fixed",
},
},
},
{
@ -247,6 +445,10 @@ function installStorybookApiFixtures() {
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsModelProfiles: true,
login: {
panelMode: "displayed_code",
timeoutPolicy: "caller_bounded",
},
},
},
]);

View File

@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard";
import {
STORYBOOK_AGENT_ID,
ONBOARDING_ARC_ENTRY_STEP,
STORYBOOK_COMPANY_ID,
clearOnboardingDraft,
readOnboardingDraft,
@ -21,7 +21,7 @@ describe("storybook onboarding draft", () => {
// story restore a saved step instead of the one it asked for. The reviewer
// then sees a screen they did not click on, which reads as a wizard bug.
it("leaves nothing behind once cleared", () => {
seedOnboardingDraft(5);
seedOnboardingDraft();
expect(readOnboardingDraft()).not.toBeNull();
clearOnboardingDraft();
@ -29,25 +29,28 @@ describe("storybook onboarding draft", () => {
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull();
});
it("writes the step the story asked for", () => {
for (const step of [3, 4, 5] as const) {
seedOnboardingDraft(step);
expect(readOnboardingDraft()?.step).toBe(step);
}
// The wizard captures `entryStep` from this draft once, at mount, and offers
// Back only while `currentStep > entryStep`. Seeding a later step is therefore
// not a shortcut to it — it is a step that can never show its Back button.
it("enters the arc at its first step, so later steps can be walked into", () => {
seedOnboardingDraft();
expect(readOnboardingDraft()?.step).toBe(ONBOARDING_ARC_ENTRY_STEP);
});
// `createdAgentId` is what `launchStateIncomplete` checks. Filling it in
// before the hire would paint over the guard step 5 is supposed to show when
// it is reached without an agent, so the earlier steps must leave it empty.
it("only claims an agent exists from the review step onward", () => {
seedOnboardingDraft(3);
// it is reached without an agent.
it("does not claim an agent exists before the hire", () => {
seedOnboardingDraft();
expect(readOnboardingDraft()?.createdAgentId).toBe("");
});
seedOnboardingDraft(4);
expect(readOnboardingDraft()?.createdAgentId).toBe("");
seedOnboardingDraft(5);
expect(readOnboardingDraft()?.createdAgentId).toBe(STORYBOOK_AGENT_ID);
// The label, not the type, was seeded here once. Nothing failed loudly: the
// connect step fell back to a real adapter, and the mismatch would only have
// surfaced as a hire posting a type the server does not know.
it("names the adapter by its type", () => {
seedOnboardingDraft();
expect(readOnboardingDraft()?.adapterType).toBe("claude_local");
});
// `restoreOnboardingState` treats restoring as an authorization decision and
@ -55,7 +58,7 @@ describe("storybook onboarding draft", () => {
// owns. Seeding a company the fixtures do not report would silently restore
// nothing, and every story would quietly fall back to its `initialStep`.
it("names the company the fixtures report as owned", () => {
seedOnboardingDraft(5);
seedOnboardingDraft();
expect(readOnboardingDraft()?.createdCompanyId).toBe(STORYBOOK_COMPANY_ID);
});

View File

@ -16,21 +16,37 @@ import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard";
export const STORYBOOK_COMPANY_ID = "company-storybook";
export const STORYBOOK_AGENT_ID = "agent-storybook";
export function seedOnboardingDraft(step: 3 | 4 | 5): void {
/** Where the agent arc begins. Every story in it enters here — see below. */
export const ONBOARDING_ARC_ENTRY_STEP = 3;
/**
* The draft a run holds when it arrives at the agent arc.
*
* It seeds the *entry* step and nothing further on purpose. The wizard offers
* Back only on a step it walked forward into `currentStep > entryStep`, and
* `entryStep` is captured once at mount from this very draft so a story that
* seeds step 4 or 5 directly renders those steps permanently without their Back
* button. Stories that want a later step click their way to it instead.
*
* `createdAgentId` is therefore absent rather than seeded: the hire happens for
* real, through the fixtured route, which is also what keeps step 5's
* `launchStateIncomplete` guard honest instead of painted over.
*/
export function seedOnboardingDraft(): void {
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step,
step: ONBOARDING_ARC_ENTRY_STEP,
companyName: "Paperclip Storybook",
agentName: "Darnold",
agentRole: "general",
adapterType: "claude_code",
// The adapter's real type, not its label. This read `claude_code`, which
// is no adapter at all — the connect step recovered by falling back, and
// the hire would have posted a type the server does not know.
adapterType: "claude_local",
createdCompanyId: STORYBOOK_COMPANY_ID,
createdCompanyPrefix: "PAP",
// Only from the review step onward. Before the hire there is no agent, and
// filling this in earlier would hide the incomplete-state guard step 5
// shows when it is reached without one.
createdAgentId: step >= 5 ? STORYBOOK_AGENT_ID : "",
createdAgentId: "",
}),
);
}

View File

@ -0,0 +1,121 @@
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import type { AdapterAuthSignal } from "@paperclipai/shared";
/**
* The environment and auth state the connect step reads, as something a story
* can choose.
*
* The step's provider sign-in panel is gated on four separate things the
* adapter declaring a login capability, a *sandbox* environment resolving, that
* environment's provider supporting a login PTY, and the auth signal coming back
* absent. Miss any one and the panel silently does not render, which looks
* exactly like it having been deleted.
*
* That is not hypothetical: the first version of these fixtures returned an
* empty environment list, and the sign-in panel was invisible in every story
* because of it. So the states are named here and selected per story rather than
* left implicit in a single hard-coded response.
*/
export type OnboardingEnvironmentState =
/** A cloud tenant as it should be: one managed sandbox, sign-in reachable. */
| "managed-sandbox"
/** The broken shape seen on staging — the step can offer no place to test. */
| "none";
export const STORYBOOK_SANDBOX_PROVIDER = "daytona";
export const STORYBOOK_SANDBOX_ENVIRONMENT_ID = "environment-storybook-sandbox";
interface FixtureState {
environments: OnboardingEnvironmentState;
authSignal: AdapterAuthSignal;
}
/**
* Mutable on purpose. The fetch fixtures are installed once, before any story
* renders, so a story cannot swap the handler it sets what the handler reads.
*/
export const onboardingFixtureState: FixtureState = {
environments: "managed-sandbox",
authSignal: "absent",
};
export function setOnboardingFixtureState(next: Partial<FixtureState>): void {
Object.assign(onboardingFixtureState, next);
}
export function resetOnboardingFixtureState(): void {
onboardingFixtureState.environments = "managed-sandbox";
onboardingFixtureState.authSignal = "absent";
}
/**
* `managedByPaperclip` and a non-local driver are what `resolveManagedSandbox
* EnvironmentId` looks for; `config.provider` is what the capability lookup keys
* on. All three have to line up or the environment resolves and the panel still
* does not appear.
*/
export function storybookEnvironments(): unknown[] {
if (onboardingFixtureState.environments === "none") return [];
return [
{
id: STORYBOOK_SANDBOX_ENVIRONMENT_ID,
companyId: "company-storybook",
name: "Managed sandbox",
driver: "sandbox",
status: "active",
config: { provider: STORYBOOK_SANDBOX_PROVIDER },
metadata: { managedByPaperclip: true },
},
];
}
export function storybookEnvironmentCapabilities(): unknown {
return {
sandboxProviders: {
[STORYBOOK_SANDBOX_PROVIDER]: { supportsLoginPty: true },
},
};
}
export function storybookAuthSignal(): { status: AdapterAuthSignal } {
return { status: onboardingFixtureState.authSignal };
}
/**
* The environment test, answering from the same auth state the sign-in panel
* reads.
*
* This is the hire's gate, not decoration. `blocksAgentCreate` stops the hire on
* a `fail`, and on any result `pass` included carrying a check whose code is
* `adapter_auth_missing`. Both shipped adapters emit that code when a sandbox
* target has no ready authentication, so a customer who has not signed in cannot
* reach the review step.
*
* An earlier version of this fixture returned `pass` with an empty check list
* whatever the auth state, which let Connect through with no model connected
* the exact defect this step exists to prevent, reproduced in the one place
* built for catching it. A fixture that always passes cannot show a gate.
*/
export function storybookEnvironmentTest(adapterType: string): unknown {
const authenticated = onboardingFixtureState.authSignal === "present";
return {
adapterType,
// `warn` rather than `fail`: the gate is the check code, and the wizard is
// explicit that a warn with no missing-auth check still hires. Using `fail`
// would pass this story for the wrong reason and hide a regression in that
// rule.
status: authenticated ? "pass" : "warn",
checks: authenticated
? []
: [
{
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
status: "warn",
title: "No working authentication",
detail: "Sign in to the provider before hiring this agent.",
},
],
testedAt: new Date(0).toISOString(),
};
}

View File

@ -193,6 +193,39 @@ export const storybookAgents: Agent[] = [
export const storybookAgentMap = new Map(storybookAgents.map((agent) => [agent.id, agent]));
/**
* The agent the onboarding hire returns.
*
* Kept out of `storybookAgents` deliberately: that list is what the company
* already has, and this one does not exist until the wizard's Connect step
* creates it. Putting it in the list would give the review step an agent it had
* not yet hired.
*/
export const storybookHiredAgent: Agent = {
id: "agent-storybook",
companyId: "company-storybook",
name: "Darnold",
urlKey: "darnold",
role: "general",
title: "Chief of Staff",
icon: "sparkles",
status: "idle",
reportsTo: null,
capabilities: "Runs the company's first workflows and hires the team behind them.",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 100_000,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: true },
lastHeartbeatAt: null,
metadata: null,
createdAt: recent(0),
updatedAt: recent(0),
};
export const storybookIssueLabels: IssueLabel[] = [
{
id: "label-ui",

View File

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, waitFor } from "storybook/test";
import { useEffect, useState } from "react";
import { OnboardingWizard } from "@/components/OnboardingWizard";
@ -7,10 +8,15 @@ import { Stepper } from "@/components/onboarding/Stepper";
import { useCompanyListQuery } from "@/api/companies-query";
import { useDialog } from "@/context/DialogContext";
import {
ONBOARDING_ARC_ENTRY_STEP,
STORYBOOK_COMPANY_ID,
clearOnboardingDraft,
seedOnboardingDraft,
} from "../fixtures/onboardingDraft";
import {
resetOnboardingFixtureState,
setOnboardingFixtureState,
} from "../fixtures/onboardingEnvironment";
/**
* The onboarding wizard's agent arc: create the agent, connect a model, review.
@ -33,8 +39,8 @@ const meta = {
export default meta;
/**
* Seeds the draft the wizard restores from, opens it, and takes the draft back
* out again on the way past.
* Seeds the draft the wizard restores from, opens it at the arc's first step,
* and takes the draft back out again on the way past.
*
* Three details the wizard's own design forces:
*
@ -49,18 +55,22 @@ export default meta;
* since localStorage is per-origin and would otherwise hand one account's draft
* to another.
*
* Step 5 is seeded rather than requested: `openOnboarding({ initialStep })`
* accepts 14 only, because the review step is somewhere the wizard arrives
* rather than somewhere it starts.
* Every story enters here, at step 3, and the later ones walk forward. Opening
* directly on a later step is the obvious shortcut and it is wrong: `entryStep`
* is captured once at mount from exactly this draft, `initialStep` sets both it
* and the current step together, and Back is offered only while
* `currentStep > entryStep`. A story opened on step 4 is a step 4 that can never
* show its Back button which is not a preview of the step, it is a preview of
* a state no customer is ever in.
*
* And the cleanup is not housekeeping. That same per-origin storage is shared
* with every other story in the session: a draft left behind makes the next
* story restore a saved step ahead of the one it asked for, so the reviewer
* lands on a screen they did not click on and reads it as a wizard bug.
*/
function WizardAtStep({ step }: { step: 3 | 4 | 5 }) {
function WizardArc() {
const [seeded] = useState(() => {
seedOnboardingDraft(step);
seedOnboardingDraft();
return true;
});
@ -80,33 +90,164 @@ function WizardAtStep({ step }: { step: 3 | 4 | 5 }) {
const { openOnboarding } = useDialog();
useEffect(() => {
if (!seeded || !ready) return;
// `initialStep` is deliberately omitted for the review step. An explicit
// option overrides the restored draft — "options take precedence over saved
// state" is the wizard's rule, not an accident — so passing one here would
// clamp 5 to 4 and land on Connect. Steps 3 and 4 pass it because being
// explicit is better when the option can express the step; step 5 cannot be
// expressed that way, so the draft carries it alone.
openOnboarding(
step <= 4
? { initialStep: step as 3 | 4, companyId: STORYBOOK_COMPANY_ID }
: { companyId: STORYBOOK_COMPANY_ID },
);
}, [seeded, ready, openOnboarding, step]);
openOnboarding({
initialStep: ONBOARDING_ARC_ENTRY_STEP,
companyId: STORYBOOK_COMPANY_ID,
});
}, [seeded, ready, openOnboarding]);
if (!ready) return null;
return <OnboardingWizard />;
}
/**
* Every wait here is given an explicit timeout because the library's default is
* one second, and every wait in this file outlasts it: the wizard does not mount
* until the companies query settles, the hire runs four requests end to end. A
* default-timeout wait gives up, the play function fails, and the story renders
* the step it started on which looks exactly like a story that was written to
* open there. That is the failure this whole file exists to avoid, so it is
* worth naming rather than inlining.
*/
const STEP_TIMEOUT_MS = 15_000;
/**
* Presses the wizard's primary button once it is enabled, and waits for the
* step it opens.
*
* The dialog is portalled to `document.body`, so the queries are scoped to the
* body rather than to `canvasElement` a canvas-scoped query finds an empty
* mount point and times out.
*
* Waiting for `toBeEnabled` is not defensive padding. Connect stays disabled
* through `adapterEnvLoading` and `missionUnresolvedForHire`, both of which
* resolve from queries, so clicking on first paint clicks a dead button and the
* story silently stops one step short of where it says it is.
*
* The button is queried again immediately before the click rather than reused
* from the wait above. The wizard re-renders as those queries land, and a node
* captured a moment earlier can be detached by the time it is clicked a click
* that raises no error and does nothing.
*/
async function advance(from: string, to: string) {
await waitFor(
() => expect(screen.getByRole("button", { name: from })).toBeEnabled(),
{ timeout: STEP_TIMEOUT_MS },
);
await userEvent.click(screen.getByRole("button", { name: from }));
await screen.findByRole("button", { name: to }, { timeout: STEP_TIMEOUT_MS });
}
/**
* Naming the organization the step before the arc, and the one a self-hosted
* run starts on. It carries no draft and no company: this is where a company is
* created, so seeding either would be describing a run that had already been
* here.
*
* Worth a story because it is dressed as the arc steps that follow it, and that
* only holds if the three are looked at together. Its Back leaves the wizard's
* steps for the front door rather than walking back through them, so it is the
* one Back on the flow that `canGoBackFromOnboardingStep` does not decide.
*/
function NamingStep() {
useEffect(() => clearOnboardingDraft, []);
const companies = useCompanyListQuery();
const ready = companies.isSuccess && companies.data !== undefined;
const { openOnboarding } = useDialog();
useEffect(() => {
if (!ready) return;
// No `companyId`: this step is where one is created, and naming the run's
// company here would be handing it the thing it exists to ask for.
openOnboarding({ initialStep: 1 });
}, [ready, openOnboarding]);
if (!ready) return null;
return <OnboardingWizard />;
}
export const NameYourOrganization: StoryObj = {
render: () => <NamingStep />,
};
/**
* The arc's first step, and the one place Back is correctly absent: a run
* entering here has nowhere behind it that belongs to it step 1 creates a
* company, and this run already holds one.
*/
export const CreateYourAgent: StoryObj = {
render: () => <WizardAtStep step={3} />,
render: () => <WizardArc />,
};
/**
* The connect step as a signed-out cloud tenant meets it: a managed sandbox
* resolves, and the provider sign-in panel is offered because the auth signal
* comes back absent.
*/
export const ConnectAModel: StoryObj = {
render: () => <WizardAtStep step={4} />,
beforeEach: () => {
setOnboardingFixtureState({
environments: "managed-sandbox",
authSignal: "absent",
});
return resetOnboardingFixtureState;
},
render: () => <WizardArc />,
play: () => advance("Next", "Connect"),
};
/**
* The same step once the provider is already authenticated. The sign-in panel
* is gone this is the only difference, and it is worth a story because the
* panel's absence is otherwise indistinguishable from it being broken.
*/
export const ConnectAModelAlreadySignedIn: StoryObj = {
beforeEach: () => {
setOnboardingFixtureState({
environments: "managed-sandbox",
authSignal: "present",
});
return resetOnboardingFixtureState;
},
render: () => <WizardArc />,
play: () => advance("Next", "Connect"),
};
/**
* No managed sandbox to test against.
*
* This is the state a walker actually hit on staging, and the step is honest
* about it rather than passing and stranding them later. Worth being able to
* look at without breaking a stack to get there.
*/
export const ConnectAModelNoSandbox: StoryObj = {
beforeEach: () => {
setOnboardingFixtureState({ environments: "none", authSignal: "unknown" });
return resetOnboardingFixtureState;
},
render: () => <WizardArc />,
play: () => advance("Next", "Connect"),
};
/**
* The review step, reached by hiring rather than by claiming a hire happened.
*
* Walking the whole arc is what makes this an honest preview of the step: the
* Back button is offered because the run genuinely walked forward into it, and
* `launchStateIncomplete` is satisfied because an agent genuinely exists. A
* seeded `createdAgentId` would paint over that guard rather than clear it.
*/
export const Review: StoryObj = {
render: () => <WizardAtStep step={5} />,
beforeEach: () => {
setOnboardingFixtureState({
environments: "managed-sandbox",
authSignal: "present",
});
return resetOnboardingFixtureState;
},
render: () => <WizardArc />,
play: async () => {
await advance("Next", "Connect");
await advance("Connect", "Get started");
},
};
export const ProgressStrip: StoryObj = {
@ -156,7 +297,10 @@ export const PillMorph: StoryObj = {
}, []);
return (
<div className="flex flex-col items-center gap-4">
<PillGuy state={alive ? "alive" : "dormant"} className="size-(--sz-72px)" />
<PillGuy
state={alive ? "alive" : "dormant"}
className="size-(--sz-72px)"
/>
<button
type="button"
onClick={() => setAlive((v) => !v)}

View File

@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ConnectModelPreview } from "@/components/onboarding/ConnectModelPreview";
/**
* The connect step's two credential states, from the PCLP-Onboarding file
* (nodes 2941:8291 and 2933:4592).
*
* The screen itself lives in `ConnectModelPreview`, which carries the note on
* what this mock does and does not stand in for. These stories are the two
* frames the design pins down, plus the state that comes before them.
*
* Worth clicking rather than reading: the stroke means "chosen" and nothing
* else, so hovering a tile brings its surface up to the same half-strength
* ground a selected tile sits on and leaves the border alone. And the
* credential tag is per-tile while the mode is not the checkbox flips all
* three at once, with the labels swapping in place rather than re-rendering, so
* it reads as one row changing its terms.
*/
const meta = {
title: "Onboarding/Connect a model",
parameters: { layout: "centered" },
} satisfies Meta;
export default meta;
/**
* How the step opens: no source picked, so Connect is disabled. This is the
* state that makes the disabled CTA reachable at all both Figma frames show
* Claude Code already selected.
*/
export const Default: StoryObj = {
render: () => <ConnectModelPreview />,
};
/** Node 2941:8291 — a source picked, credentials left on the subscription. */
export const SubscriptionSelected: StoryObj = {
render: () => <ConnectModelPreview initialSourceId="claude_local" />,
};
/** Node 2933:4592 — the same selection with the API-key toggle on. */
export const ApiKeysSelected: StoryObj = {
render: () => <ConnectModelPreview initialSourceId="claude_local" initialUseApiKeys />,
};
/**
* Alternate: the checkbox row replaced by a line of text that renames itself
* on press "Use API keys instead" becomes "Use subscription instead", fading
* out fast and back in while the tags slide.
*
* The trade to look at is what the row no longer tells you. A checkbox shows
* the current mode whether or not you read the sentence; this control can only
* name where pressing it takes you, so the tiles' tags become the sole answer
* to "which am I on". Worth pressing twice to see whether that holds.
*/
export const LinkAlternate: StoryObj = {
render: () => <ConnectModelPreview control="link" initialSourceId="claude_local" />,
};
/** The link alternate already switched over, for the two labels side by side. */
export const LinkAlternateOnApiKeys: StoryObj = {
render: () => (
<ConnectModelPreview control="link" initialSourceId="claude_local" initialUseApiKeys />
),
};

View File

@ -0,0 +1,65 @@
import fs from "node:fs";
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Builds the standalone connect-step mock (`connect-model-preview.html`) on its
* own, for deploying as a static page that a reviewer can open from a link.
*
* A separate config rather than a second rollup input on the app's build, and
* the reason is what ends up on the host rather than tidiness: a shared build
* emits the app's ~6MB `main` chunk into the same `dist/assets`, and every file
* in the deployed directory is publicly fetchable whether or not anything links
* to it. Building alone means the deployed bundle is this screen and the pieces
* it composes, full stop. It also leaves the app's own build untouched.
*
* pnpm --filter @paperclipai/ui build:preview
*/
const OUT_DIR = "dist-preview";
/**
* Land the entry as `index.html` so the mock is the site root and the output
* directory deploys as-is no rename step to forget on a redeploy.
*
* Renamed on disk in `closeBundle` rather than rekeyed in `generateBundle`:
* Vite's own HTML plugin emits the document after user plugins have had their
* `generateBundle` turn, so a bundle-level rename finds nothing to rename. The
* document's asset links are absolute (`/assets/...`), so moving the file
* itself breaks nothing.
*/
const previewAsIndex = {
name: "preview-html-as-index",
closeBundle() {
const built = path.resolve(__dirname, OUT_DIR, "connect-model-preview.html");
if (!fs.existsSync(built)) return;
fs.renameSync(built, path.resolve(__dirname, OUT_DIR, "index.html"));
},
};
export default defineConfig({
plugins: [react(), tailwindcss(), previewAsIndex],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/dist/Lexical.mjs"),
},
},
build: {
outDir: OUT_DIR,
emptyOutDir: true,
minify: "esbuild",
rollupOptions: {
input: path.resolve(__dirname, "connect-model-preview.html"),
},
},
esbuild: {
drop: ["console", "debugger"],
legalComments: "none",
},
});