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:
TonioandClaude Opus 5 authored and GitHub committed 2026-09-01 09:57:46 -07:00
1 parent 86ebdf842e
commit 42c6f8a424
31 files changed
+2585 -361

No files matched your search

@@ -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",
);
+37 -6
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>
+178 -7
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());
});
+463 -208
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 1–3 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"
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
+10 -5
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}
>
@@ -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>
);
}
+177
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>
);
}
+38 -1
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);
}
});
});
+75 -28
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>
@@ -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;
+86
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>,
);
+1
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%;
+7 -7
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", {