diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 5643e371d8..b4a1b93276 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -3,6 +3,7 @@ import type { Preview } from "@storybook/react-vite"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { WorkTimelineResult } from "@paperclipai/shared"; import { MemoryRouter } from "@/lib/router"; +import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard"; import { BreadcrumbProvider } from "@/context/BreadcrumbContext"; import { CompanyProvider } from "@/context/CompanyContext"; import { DialogProvider } from "@/context/DialogContext"; @@ -130,6 +131,23 @@ function installStorybookApiFixtures() { }); } + if (url.pathname === "/api/instance/settings") { + return Response.json({}); + } + + // The onboarding wizard's connect step reads these. An empty environment + // list is the cloud-tenant shape — agents run in a managed sandbox rather + // than a configured environment — and it is also the state that produces + // the "no managed sandbox environment is available" notice, which is worth + // being able to look at rather than only meeting it on a live stack. + if (/^\/api\/companies\/[^/]+\/environments$/.test(url.pathname)) { + return Response.json([]); + } + + if (/^\/api\/companies\/[^/]+\/adapters\/[^/]+\/models$/.test(url.pathname)) { + return Response.json([]); + } + if (url.pathname === "/api/adapters") { return Response.json([ { @@ -449,6 +467,28 @@ const preview: Preview = { }, }, }, + + /** + * Every story starts without an onboarding draft. + * + * `localStorage` is per-origin and the preview frame keeps one for the whole + * session, so a story that seeds a draft would otherwise hand it to whatever a + * reviewer opens next: the wizard restores that saved step ahead of the step + * the new story asked for, and the reviewer lands on a screen they never + * clicked on. + * + * Cleared here rather than on the seeding story's unmount, deliberately. + * Switching stories navigates the preview iframe, so the page is torn down + * rather than React-unmounted and an unmount cleanup never runs — which is + * exactly how the first attempt at this leaked anyway. + */ + beforeEach: () => { + try { + window.localStorage.removeItem(ONBOARDING_STORAGE_KEY); + } catch { + // Storage access throws in some privacy modes; nothing to clean up there. + } + }, }; export default preview; diff --git a/ui/storybook/fixtures/onboardingDraft.test.ts b/ui/storybook/fixtures/onboardingDraft.test.ts new file mode 100644 index 0000000000..d086011d7f --- /dev/null +++ b/ui/storybook/fixtures/onboardingDraft.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest"; + +import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard"; +import { + STORYBOOK_AGENT_ID, + STORYBOOK_COMPANY_ID, + clearOnboardingDraft, + readOnboardingDraft, + seedOnboardingDraft, +} from "./onboardingDraft"; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("storybook onboarding draft", () => { + // The leak this exists to stop: `localStorage` is per-origin and shared by + // every story in a session, so a seeded draft left behind makes the *next* + // story restore a saved step instead of the one it asked for. The reviewer + // then sees a screen they did not click on, which reads as a wizard bug. + it("leaves nothing behind once cleared", () => { + seedOnboardingDraft(5); + expect(readOnboardingDraft()).not.toBeNull(); + + clearOnboardingDraft(); + expect(readOnboardingDraft()).toBeNull(); + expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull(); + }); + + it("writes the step the story asked for", () => { + for (const step of [3, 4, 5] as const) { + seedOnboardingDraft(step); + expect(readOnboardingDraft()?.step).toBe(step); + } + }); + + // `createdAgentId` is what `launchStateIncomplete` checks. Filling it in + // before the hire would paint over the guard step 5 is supposed to show when + // it is reached without an agent, so the earlier steps must leave it empty. + it("only claims an agent exists from the review step onward", () => { + seedOnboardingDraft(3); + expect(readOnboardingDraft()?.createdAgentId).toBe(""); + + seedOnboardingDraft(4); + expect(readOnboardingDraft()?.createdAgentId).toBe(""); + + seedOnboardingDraft(5); + expect(readOnboardingDraft()?.createdAgentId).toBe(STORYBOOK_AGENT_ID); + }); + + // `restoreOnboardingState` treats restoring as an authorization decision and + // throws the whole blob away when the saved company is not one the account + // owns. Seeding a company the fixtures do not report would silently restore + // nothing, and every story would quietly fall back to its `initialStep`. + it("names the company the fixtures report as owned", () => { + seedOnboardingDraft(5); + expect(readOnboardingDraft()?.createdCompanyId).toBe(STORYBOOK_COMPANY_ID); + }); + + it("survives a malformed value without throwing", () => { + window.localStorage.setItem(ONBOARDING_STORAGE_KEY, "{not json"); + expect(readOnboardingDraft()).toBeNull(); + }); +}); diff --git a/ui/storybook/fixtures/onboardingDraft.ts b/ui/storybook/fixtures/onboardingDraft.ts new file mode 100644 index 0000000000..2af977c54c --- /dev/null +++ b/ui/storybook/fixtures/onboardingDraft.ts @@ -0,0 +1,50 @@ +import { ONBOARDING_STORAGE_KEY } from "@/components/OnboardingWizard"; + +/** + * The onboarding draft, as the wizard stories need to write it. + * + * `localStorage` is per-origin, so every story in a Storybook session shares + * one. A story that seeds a draft and walks away leaves it for the next one: + * the wizard restores a saved step ahead of whatever step that story asked for, + * and the reviewer gets a screen they did not click on. So seeding and clearing + * are a pair, and they live here rather than inline so the pairing is testable. + * + * The key is imported rather than restated. It is the wizard's, and a second + * copy of a storage key is a bug waiting for the first one to be renamed. + */ + +export const STORYBOOK_COMPANY_ID = "company-storybook"; +export const STORYBOOK_AGENT_ID = "agent-storybook"; + +export function seedOnboardingDraft(step: 3 | 4 | 5): void { + window.localStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ + step, + companyName: "Paperclip Storybook", + agentName: "Darnold", + agentRole: "general", + adapterType: "claude_code", + createdCompanyId: STORYBOOK_COMPANY_ID, + createdCompanyPrefix: "PAP", + // Only from the review step onward. Before the hire there is no agent, and + // filling this in earlier would hide the incomplete-state guard step 5 + // shows when it is reached without one. + createdAgentId: step >= 5 ? STORYBOOK_AGENT_ID : "", + }), + ); +} + +export function clearOnboardingDraft(): void { + window.localStorage.removeItem(ONBOARDING_STORAGE_KEY); +} + +export function readOnboardingDraft(): Record | null { + const raw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY); + if (raw === null) return null; + try { + return JSON.parse(raw) as Record; + } catch { + return null; + } +} diff --git a/ui/storybook/stories/onboarding-agent-arc.stories.tsx b/ui/storybook/stories/onboarding-agent-arc.stories.tsx index 710827d1a8..d4ec67077c 100644 --- a/ui/storybook/stories/onboarding-agent-arc.stories.tsx +++ b/ui/storybook/stories/onboarding-agent-arc.stories.tsx @@ -1,12 +1,29 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AgentCapsule } from "@/components/AgentCapsule"; +import { useEffect, useState } from "react"; + +import { OnboardingWizard } from "@/components/OnboardingWizard"; +import { PillGuy } from "@/components/onboarding/PillGuy"; import { Stepper } from "@/components/onboarding/Stepper"; +import { useCompanyListQuery } from "@/api/companies-query"; +import { useDialog } from "@/context/DialogContext"; +import { + STORYBOOK_COMPANY_ID, + clearOnboardingDraft, + seedOnboardingDraft, +} from "../fixtures/onboardingDraft"; /** - * The onboarding wizard's agent arc: create the agent, connect it, review. - * These are the three steps a customer walks inside the tenant — company - * creation happens in Cloud before they arrive, which is why the strip counts - * to three rather than to the wizard's own step numbers. + * The onboarding wizard's agent arc: create the agent, connect a model, review. + * These are the three steps a customer walks inside the tenant — the + * organization is named in Cloud before they arrive, which is why the strip + * counts to three rather than to the wizard's own step numbers. + * + * The step stories below mount the real wizard against the Storybook API + * fixtures. That matters more here than in most stories: these screens only + * render for a signed-in account that owns a provisioned stack, so before this + * existed the only way to see them was to walk a real signup — and when the + * connect step failed on a live stack, the review step behind it could not be + * reached at all. */ const meta = { title: "Onboarding/Agent arc", @@ -15,6 +32,83 @@ const meta = { export default meta; +/** + * Seeds the draft the wizard restores from, opens it, and takes the draft back + * out again on the way past. + * + * Three details the wizard's own design forces: + * + * The draft is written during render, not in an effect. Roughly twenty + * `useState(saved?.x ?? default)` initializers read the restored blob exactly + * once, on first render, so a draft written after mount arrives too late to + * matter. + * + * `createdCompanyId` has to be a company the fixtures report as owned. + * `restoreOnboardingState` treats restoring as an authorization decision and + * discards the whole blob when the saved company is not in the list — correctly, + * since localStorage is per-origin and would otherwise hand one account's draft + * to another. + * + * Step 5 is seeded rather than requested: `openOnboarding({ initialStep })` + * accepts 1–4 only, because the review step is somewhere the wizard arrives + * rather than somewhere it starts. + * + * And the cleanup is not housekeeping. That same per-origin storage is shared + * with every other story in the session: a draft left behind makes the next + * story restore a saved step ahead of the one it asked for, so the reviewer + * lands on a screen they did not click on and reads it as a wizard bug. + */ +function WizardAtStep({ step }: { step: 3 | 4 | 5 }) { + const [seeded] = useState(() => { + seedOnboardingDraft(step); + return true; + }); + + useEffect(() => clearOnboardingDraft, []); + + // Nothing is mounted until the companies list has settled, and that ordering + // is load-bearing rather than tidiness. The wizard's own mount gate waits on + // `isFetching`, but this query is *disabled* until the account settles, and a + // disabled query is not fetching — so mounting immediately gets an inner + // wizard whose ~20 one-shot initializers read a null draft, take `initialStep` + // instead, and then persist that back over the seed. A real session does not + // hit this because the dashboard has already loaded the list by the time + // anyone opens onboarding. + const companies = useCompanyListQuery(); + const ready = companies.isSuccess && companies.data !== undefined; + + const { openOnboarding } = useDialog(); + useEffect(() => { + if (!seeded || !ready) return; + // `initialStep` is deliberately omitted for the review step. An explicit + // option overrides the restored draft — "options take precedence over saved + // state" is the wizard's rule, not an accident — so passing one here would + // clamp 5 to 4 and land on Connect. Steps 3 and 4 pass it because being + // explicit is better when the option can express the step; step 5 cannot be + // expressed that way, so the draft carries it alone. + openOnboarding( + step <= 4 + ? { initialStep: step as 3 | 4, companyId: STORYBOOK_COMPANY_ID } + : { companyId: STORYBOOK_COMPANY_ID }, + ); + }, [seeded, ready, openOnboarding, step]); + + if (!ready) return null; + return ; +} + +export const CreateYourAgent: StoryObj = { + render: () => , +}; + +export const ConnectAModel: StoryObj = { + render: () => , +}; + +export const Review: StoryObj = { + render: () => , +}; + export const ProgressStrip: StoryObj = { render: () => (
@@ -26,15 +120,15 @@ export const ProgressStrip: StoryObj = { }; /** - * The capsule's three states, which the wizard holds in one tree slot so the - * morph reads as a single object coming to life rather than three renders. + * The agent's two states, side by side. `dormant` waits to be configured; + * `alive` is the hired agent on the review step. */ -export const CapsuleStates: StoryObj = { +export const PillStates: StoryObj = { render: () => (
- {(["slot", "configured", "online"] as const).map((state) => ( + {(["dormant", "alive"] as const).map((state) => (
- + {state} @@ -45,20 +139,32 @@ export const CapsuleStates: StoryObj = { }; /** - * The same three states rendered with the default cross-fade, for comparison - * with the traced outline above. + * The transition on its own, on a loop. + * + * Worth a story of its own because it is the arc's payoff and the hardest part + * to judge from a still. The two states share a silhouette but differ in fill, + * eye shape, and a tuft the dormant state does not have at all, so they + * cross-fade rather than path-morph — there is no honest interpolation between + * them, and a faked one warps the eyes through shapes the design never draws. */ -export const CapsuleStatesCrossfade: StoryObj = { - render: () => ( -
- {(["slot", "configured", "online"] as const).map((state) => ( -
- - - {state} - -
- ))} -
- ), +export const PillMorph: StoryObj = { + render: function PillMorphStory() { + const [alive, setAlive] = useState(false); + useEffect(() => { + const id = setInterval(() => setAlive((v) => !v), 1800); + return () => clearInterval(id); + }, []); + return ( +
+ + +
+ ); + }, };