mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
fix(ui): show progress and verify subscriptions during onboarding (#13499)
Automatically verify detected Claude and Codex subscriptions during initial onboarding. Show connection progress, support retries, and ignore stale results after navigation. Prefer the personal default subscription while preserving the later create-agent chooser. Allow Enter to advance from the agent name field. Add regression tests and production-component Storybook coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9adeabb590
commit
669bd0e7a9
5 files changed
+492
-77
No files matched your search
@@ -17,7 +17,7 @@ vi.mock("@/api/health", () => ({ healthApi: localHealth }));
|
||||
const managedApi = vi.hoisted(() => ({
|
||||
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
|
||||
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", command: "CODEX_HOME='/fixture/login' codex login", expiresAt: "2026-09-11T20:00:00Z" })),
|
||||
checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as const })),
|
||||
checkLocalLogin: vi.fn(async () => ({ status: "sign_in_required" as "sign_in_required" | "ready" })),
|
||||
cancelLocalLogin: vi.fn(async () => ({})),
|
||||
connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })),
|
||||
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
|
||||
@@ -324,6 +324,8 @@ function isArcPrimary(text: string): boolean {
|
||||
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
|
||||
beforeEach(() => {
|
||||
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" });
|
||||
managedApi.checkLocalLogin.mockReset().mockResolvedValue({ status: "sign_in_required" });
|
||||
managedApi.connectLocal.mockReset().mockResolvedValue({ connectionId: "local-connection", grantId: "local-grant" });
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-b", userId: SESSION_USER_ID },
|
||||
user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null },
|
||||
@@ -1050,9 +1052,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
latestVersion: 1,
|
||||
});
|
||||
const { root, clickByText } = await openConnectStep();
|
||||
|
||||
await clickByText((t) => isArcPrimary(t));
|
||||
const { root } = await openConnectStep();
|
||||
// Selecting the provider automatically verifies the saved subscription.
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalled();
|
||||
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
|
||||
@@ -1100,9 +1102,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
mockAdapterBuild.buildAdapterConfig.mockReturnValue({
|
||||
env: { ANTHROPIC_API_KEY: { type: "plain", value: "sk-ant-configured" } },
|
||||
});
|
||||
const { root, clickByText } = await openConnectStep();
|
||||
|
||||
await clickByText((t) => isArcPrimary(t));
|
||||
const { root } = await openConnectStep();
|
||||
// Selecting the provider automatically verifies the saved subscription.
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalled();
|
||||
// Discovery reads saved-login metadata once; the hire does not re-read
|
||||
@@ -1125,9 +1127,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
latestVersion: 1,
|
||||
});
|
||||
const { root, clickByText } = await openConnectStep();
|
||||
|
||||
await clickByText((t) => isArcPrimary(t));
|
||||
const { root } = await openConnectStep();
|
||||
// Selecting the provider automatically verifies the saved subscription.
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalled();
|
||||
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
|
||||
@@ -1150,9 +1152,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
secretId: "11111111-1111-1111-1111-111111111111",
|
||||
latestVersion: 1,
|
||||
});
|
||||
const { root, clickByText } = await openConnectStep();
|
||||
|
||||
await clickByText((t) => isArcPrimary(t));
|
||||
const { root } = await openConnectStep();
|
||||
// Selecting the provider automatically verifies the saved subscription.
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalled();
|
||||
const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[];
|
||||
@@ -1223,9 +1225,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
import("@paperclipai/shared").AdapterEnvironmentTestResult
|
||||
>,
|
||||
);
|
||||
const { root, clickByText } = await openConnectStep();
|
||||
|
||||
await clickByText((t) => isArcPrimary(t));
|
||||
const { root } = await openConnectStep();
|
||||
// Selecting the provider automatically verifies the saved subscription.
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalled();
|
||||
|
||||
@@ -2772,7 +2774,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
// And the button goes on saying what is happening rather than going quiet.
|
||||
expect(
|
||||
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim(),
|
||||
).toBe("Connecting");
|
||||
).toBe("Connecting…");
|
||||
|
||||
await act(async () => finishHire({ agent: { id: "agent-1" }, approval: null }));
|
||||
for (let i = 0; i < 6; i++) await flushReact();
|
||||
@@ -3089,6 +3091,251 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
describe("automatic subscription verification", () => {
|
||||
const providers = [
|
||||
["claude_local", "anthropic", /Claude/],
|
||||
["codex_local", "openai", /OpenAI/],
|
||||
] as const;
|
||||
const passed = { adapterType: "codex_local", status: "pass" as const, checks: [], testedAt: new Date().toISOString() };
|
||||
|
||||
beforeEach(() => {
|
||||
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
|
||||
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await flushReact();
|
||||
}
|
||||
function button(label: string) {
|
||||
const found = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === label);
|
||||
expect(found, label).toBeTruthy();
|
||||
return found!;
|
||||
}
|
||||
async function click(label: string) {
|
||||
await act(async () => button(label).click());
|
||||
await settle();
|
||||
}
|
||||
async function detected() {
|
||||
managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" });
|
||||
await act(async () => window.dispatchEvent(new Event("focus")));
|
||||
await settle();
|
||||
}
|
||||
function expectTesting() {
|
||||
expect(button("Testing…").disabled).toBe(true);
|
||||
expect(button("Testing…").querySelector("svg.animate-spin")).not.toBeNull();
|
||||
expect([...document.body.querySelectorAll('[role="status"]')].some(e => e.textContent === "Testing connection…")).toBe(true);
|
||||
expect(document.body.querySelector('[aria-label="Saved subscription"]')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("Run this in a terminal");
|
||||
expect(document.body.textContent).not.toContain("Start sign-in again");
|
||||
}
|
||||
|
||||
it.each(providers)("automatically verifies detected %s once through connection refreshes and advances", async (adapterType, provider, label) => {
|
||||
let finish!: (result: typeof passed) => void;
|
||||
mockAgentsApi.testEnvironment.mockReturnValue(new Promise(resolve => { finish = resolve; }));
|
||||
const { root, queryClient } = await openStep4({ adapterType });
|
||||
try {
|
||||
await pickSource(label);
|
||||
expect(document.body.textContent).toContain("Run this in a terminal");
|
||||
expect(button("Connect").disabled).toBe(false);
|
||||
await detected();
|
||||
expectTesting();
|
||||
// The saved account can become visible while its hello test is pending.
|
||||
await act(async () => {
|
||||
queryClient.setQueryData(["ai-connections", "company-new"], {
|
||||
currentUserId: "user-1",
|
||||
connections: [{ id: "local-connection", grantId: "local-grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||||
});
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await settle();
|
||||
expectTesting();
|
||||
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
await act(async () => finish(passed));
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("is ready to work!");
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }) }));
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(providers)("automatically verifies a saved %s subscription only after selecting its provider", async (adapterType, provider, label) => {
|
||||
const { root, queryClient } = await openStep4({ adapterType });
|
||||
try {
|
||||
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
|
||||
currentUserId: "user-1",
|
||||
connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||||
}));
|
||||
await settle();
|
||||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||||
await pickSource(label);
|
||||
await settle();
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(managedApi.connectLocal).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("is ready to work!");
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(providers)("prefers the personal default %s subscription over an earlier shared account", async (adapterType, provider, label) => {
|
||||
const { root, queryClient } = await openStep4({ adapterType });
|
||||
try {
|
||||
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
|
||||
currentUserId: "user-1",
|
||||
connections: [
|
||||
{ id: "shared", grantId: "shared-grant", companyId: "company-new", provider, method: "subscription", name: "Shared", ownership: "shared", status: "connected" },
|
||||
{ id: "personal", grantId: "personal-grant", companyId: "company-new", provider, method: "subscription", name: "Personal", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true },
|
||||
],
|
||||
}));
|
||||
await pickSource(label);
|
||||
await settle();
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({
|
||||
runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }),
|
||||
}));
|
||||
expect(managedApi.connectLocal).not.toHaveBeenCalled();
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(providers)("does not choose an arbitrary %s account when several shared subscriptions exist", async (adapterType, provider, label) => {
|
||||
const { root, queryClient } = await openStep4({ adapterType });
|
||||
try {
|
||||
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
|
||||
currentUserId: "user-1",
|
||||
connections: ["first", "second"].map(id => ({ id, grantId: `${id}-grant`, companyId: "company-new", provider, method: "subscription", name: id, ownership: "shared", status: "connected" })),
|
||||
}));
|
||||
await pickSource(label);
|
||||
await settle();
|
||||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Run this in a terminal");
|
||||
expect(button("Connect").disabled).toBe(false);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(providers)("keeps %s verification pending through environment query updates", async (adapterType, _provider, label) => {
|
||||
let finish!: (result: typeof passed) => void;
|
||||
mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
|
||||
const { root, queryClient } = await openStep4({ adapterType });
|
||||
try {
|
||||
await pickSource(label);
|
||||
await detected();
|
||||
expectTesting();
|
||||
await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [
|
||||
{ ...LOCAL_ENVIRONMENT, id: "new-local-default" },
|
||||
]));
|
||||
await settle();
|
||||
expectTesting();
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
await act(async () => finish(passed));
|
||||
await settle();
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).toContain("is ready to work!");
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(["back", "unmount"] as const)("ignores an already-submitted hire response after %s", async navigation => {
|
||||
let finishHire!: (value: { agent: { id: string }; approval: null }) => void;
|
||||
mockAgentsApi.hire.mockReturnValueOnce(new Promise(resolve => { finishHire = resolve; }));
|
||||
const { root } = await openStep4({ adapterType: "codex_local" });
|
||||
let mounted = true;
|
||||
try {
|
||||
await pickSource(/OpenAI/);
|
||||
await detected();
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||||
if (navigation === "back") await click("Back");
|
||||
else {
|
||||
await act(async () => root.unmount());
|
||||
mounted = false;
|
||||
}
|
||||
await act(async () => finishHire({ agent: { id: "agent-1" }, approval: null }));
|
||||
await settle();
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).not.toContain("is ready to work!");
|
||||
} finally { if (mounted) await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(providers)("offers a manual retry after failed %s verification without signing in again", async (adapterType, _provider, label) => {
|
||||
mockAgentsApi.testEnvironment.mockRejectedValueOnce(new Error("Provider request timed out. Try again."));
|
||||
const { root } = await openStep4({ adapterType });
|
||||
try {
|
||||
await pickSource(label);
|
||||
await detected();
|
||||
expect(document.body.textContent).toContain("Provider request timed out. Try again.");
|
||||
expect(button("Connect").disabled).toBe(false);
|
||||
await detected();
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
await click("Connect");
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
|
||||
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).toContain("is ready to work!");
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(providers)("keeps Connect usable when %s detection has not completed", async (adapterType, _provider, label) => {
|
||||
const { root } = await openStep4({ adapterType });
|
||||
try {
|
||||
await pickSource(label);
|
||||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||||
await click("Connect");
|
||||
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).toContain("is ready to work!");
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it("shows Connecting while saving, then Testing while verifying", async () => {
|
||||
let saved!: (result: { connectionId: string; grantId: string }) => void;
|
||||
managedApi.connectLocal.mockReturnValue(new Promise(resolve => { saved = resolve; }));
|
||||
mockAgentsApi.testEnvironment.mockReturnValue(new Promise(() => {}));
|
||||
const { root } = await openStep4({ adapterType: "codex_local" });
|
||||
try {
|
||||
await pickSource(/OpenAI/);
|
||||
await detected();
|
||||
expect(button("Connecting…").disabled).toBe(true);
|
||||
expect(document.body.textContent).not.toContain("Run this in a terminal");
|
||||
await act(async () => saved({ connectionId: "local-connection", grantId: "local-grant" }));
|
||||
await settle();
|
||||
expectTesting();
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it.each(["back", "provider", "company", "unmount"] as const)("ignores verification completion after %s navigation", async navigation => {
|
||||
let finish!: (result: typeof passed) => void;
|
||||
mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
|
||||
const { root, queryClient } = await openStep4({ adapterType: "codex_local" });
|
||||
let mounted = true;
|
||||
try {
|
||||
await pickSource(/OpenAI/);
|
||||
await detected();
|
||||
expectTesting();
|
||||
if (navigation === "company") {
|
||||
mockCompany.companies.push({ id: "company-other", name: "Other", issuePrefix: "OTH" });
|
||||
mockDialog.onboardingOptions = { initialStep: 4, companyId: "company-other" };
|
||||
managedApi.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" });
|
||||
await act(async () => root.render(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
|
||||
await settle();
|
||||
} else if (navigation === "unmount") {
|
||||
await act(async () => root.unmount());
|
||||
mounted = false;
|
||||
} else {
|
||||
await click("Back");
|
||||
if (navigation === "provider") {
|
||||
managedApi.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" });
|
||||
await pickSource(/Claude/);
|
||||
}
|
||||
}
|
||||
await act(async () => finish(passed));
|
||||
await settle();
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("is ready to work!");
|
||||
if (navigation === "provider") expect(document.body.textContent).toContain("claude auth login");
|
||||
} finally { if (mounted) await act(async () => root.unmount()); }
|
||||
});
|
||||
});
|
||||
|
||||
it("shows local Claude instructions and saves its connection before hiring", async () => {
|
||||
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
|
||||
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
|
||||
|
||||
@@ -676,14 +676,10 @@ function OnboardingWizardInner({
|
||||
apiKeyEnvKeyFor(adapterType),
|
||||
effectiveOnboardingOpen && step === 4,
|
||||
);
|
||||
const [subscriptionId, setSubscriptionId] = useState<{ companyId: string; id: string } | null>(null);
|
||||
const savedSubscription = savedKeys.subscriptions.length > 0
|
||||
? savedKeys.subscriptions.find((option) => option.id === (
|
||||
subscriptionId?.companyId === createdCompanyId
|
||||
? subscriptionId.id
|
||||
: savedKeys.subscriptions[0]?.id
|
||||
))
|
||||
: undefined;
|
||||
// The chooser is absent in onboarding. Prefer the user's explicit default;
|
||||
// otherwise only reuse an unambiguous account, regardless of list ordering.
|
||||
const savedSubscription = savedKeys.subscriptions.find((option) => option.aiConnection?.mode === "responsible_user")
|
||||
?? (savedKeys.subscriptions.length === 1 ? savedKeys.subscriptions[0] : undefined);
|
||||
const [selectedSavedKey, setSelectedSavedKey] = useState<{ companyId: string; envKey: string; id: string } | null>(null);
|
||||
const selectedApiKeyId = selectedSavedKey?.companyId === createdCompanyId && selectedSavedKey?.envKey === apiKeyEnvKeyFor(adapterType)
|
||||
? selectedSavedKey.id
|
||||
@@ -725,7 +721,9 @@ function OnboardingWizardInner({
|
||||
// Connect handler re-runs a cached failed probe — and two overlapping
|
||||
// submissions could then both pass the fresh probe and both hire. `loading`
|
||||
// cannot stop the second caller for the same reason as above.
|
||||
const hiringAgentRef = useRef(false);
|
||||
const hiringAgentRef = useRef<number | null>(null);
|
||||
const connectAttemptRef = useRef(0);
|
||||
const autoConnectStartedRef = useRef(false);
|
||||
// True when the last `adapterEnvResult` came from a config that carried
|
||||
// the fixed Claude login binding (see `hireAdapterConfig` in
|
||||
// `handleGiveHeartbeat`). A cached result from a config that did not carry
|
||||
@@ -1000,6 +998,19 @@ function OnboardingWizardInner({
|
||||
}, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" &&
|
||||
Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(),
|
||||
{ allowHostClaude: localLoginHealth.data?.deploymentMode === "local_trusted" });
|
||||
// A result from a previous selection must not hire or advance this wizard.
|
||||
// Environment query updates are not user navigation: the test resolves its
|
||||
// own environment, and those updates must not interrupt the pending attempt.
|
||||
useEffect(() => {
|
||||
autoConnectStartedRef.current = false;
|
||||
hiringAgentRef.current = null;
|
||||
if (step === 4) {
|
||||
setLoading(false);
|
||||
setAdapterEnvLoading(false);
|
||||
}
|
||||
return () => { connectAttemptRef.current++; };
|
||||
}, [effectiveOnboardingOpen, createdCompanyId, adapterType, credentialMode, step]);
|
||||
|
||||
const canShowAdapterLogin = Boolean(
|
||||
adapterCaps.login != null &&
|
||||
resolvedLoginEnvironment?.driver === "sandbox" &&
|
||||
@@ -1154,7 +1165,7 @@ function OnboardingWizardInner({
|
||||
// poll. Keep its controller mounted until it reports success; otherwise
|
||||
// the saved account replaces the panel and "Connecting" never finishes.
|
||||
(connectAuthUrl || (
|
||||
(showAdapterLoginPanel || (canShowAdapterLogin && adapterType === "codex_local" && subscriptionId?.companyId === createdCompanyId && subscriptionId.id === "")) &&
|
||||
showAdapterLoginPanel &&
|
||||
!savedSubscription &&
|
||||
!(adapterType === "claude_local" && savedKeys.storedLogin.data)
|
||||
)) &&
|
||||
@@ -1188,7 +1199,10 @@ function OnboardingWizardInner({
|
||||
*/
|
||||
const connectCollapsed =
|
||||
connectPhase !== "idle" && connectPhase !== "unwindRow" && sourceSelected;
|
||||
const connectHasCard = credentialMode === "api" || connectStepNeedsLogin || connectStepHasNoSandbox;
|
||||
const connectProgress = adapterEnvLoading ? "Testing connection…" : loading ? "Connecting…" : null;
|
||||
const hasSavedSubscription = Boolean(savedSubscription || savedKeys.storedLogin.data ||
|
||||
(credentialMode !== "api" && managedBindingForStep()));
|
||||
const connectHasCard = credentialMode === "api" || connectStepNeedsLogin || connectStepHasNoSandbox || Boolean(connectProgress);
|
||||
const connectCardLive =
|
||||
connectHasCard &&
|
||||
(connectPhase === "loading" ||
|
||||
@@ -1236,6 +1250,21 @@ function OnboardingWizardInner({
|
||||
const connectStepLoggingIn =
|
||||
connectStepNeedsLogin && connectPhase !== "idle" && connectPhase !== "connecting";
|
||||
|
||||
// Detecting a credential is enough to start verification once the user has
|
||||
// chosen Claude or Codex. A saved-list refresh during that work is not a new
|
||||
// attempt, and a failed attempt waits for an explicit Connect retry.
|
||||
useEffect(() => {
|
||||
if (!effectiveOnboardingOpen || step !== 4 || connectPhase !== "ready" ||
|
||||
!connectStepReady || connectStepNeedsLogin || connectCredentialStored ||
|
||||
credentialMode !== "subscription" ||
|
||||
(adapterType !== "claude_local" && adapterType !== "codex_local") ||
|
||||
(!hasSavedSubscription && localLogin.status !== "ready") ||
|
||||
loading || autoConnectStartedRef.current) return;
|
||||
autoConnectStartedRef.current = true;
|
||||
void handleGiveHeartbeat();
|
||||
}, [effectiveOnboardingOpen, step, connectPhase, connectStepReady, connectStepNeedsLogin,
|
||||
connectCredentialStored, credentialMode, adapterType, hasSavedSubscription, localLogin.status, loading]);
|
||||
|
||||
/**
|
||||
* The beats, each waiting for the animation before it.
|
||||
*
|
||||
@@ -1314,7 +1343,9 @@ function OnboardingWizardInner({
|
||||
*/
|
||||
const connectSourceLabel = CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
|
||||
const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } =
|
||||
connectPhase === "waiting"
|
||||
connectProgress
|
||||
? { label: adapterEnvLoading ? "Testing…" : connectProgress, icon: "spinner", disabled: true }
|
||||
: connectPhase === "waiting"
|
||||
? { label: "Waiting for code", icon: "spinner", disabled: true }
|
||||
: connectPhase === "connecting"
|
||||
? { label: "Connecting", icon: "spinner", disabled: true }
|
||||
@@ -1350,6 +1381,10 @@ function OnboardingWizardInner({
|
||||
* the same thing that collects a session abandoned by closing the tab.
|
||||
*/
|
||||
function unwindConnectStep() {
|
||||
connectAttemptRef.current++;
|
||||
hiringAgentRef.current = null;
|
||||
setLoading(false);
|
||||
setAdapterEnvLoading(false);
|
||||
setConnectAuthUrl(null);
|
||||
connectingSinceRef.current = null;
|
||||
setConnectCredentialStored(false);
|
||||
@@ -1481,7 +1516,7 @@ function OnboardingWizardInner({
|
||||
setAdapterEnvResult(null);
|
||||
adapterEnvResultAppliedStoredLoginRef.current = false;
|
||||
setAdapterEnvError(null);
|
||||
}, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, subscriptionId, savedSubscription?.id]);
|
||||
}, [step, adapterType, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id]);
|
||||
|
||||
/**
|
||||
* Leaving the step puts the row back to a question.
|
||||
@@ -1823,7 +1858,8 @@ function OnboardingWizardInner({
|
||||
|
||||
async function runAdapterEnvironmentTest(
|
||||
adapterConfigOverride?: Record<string, unknown>,
|
||||
appliedStoredClaudeLoginBinding = false
|
||||
appliedStoredClaudeLoginBinding = false,
|
||||
isCurrent = () => true,
|
||||
): Promise<AdapterEnvironmentTestResult | null> {
|
||||
if (!createdCompanyId) {
|
||||
setAdapterEnvError(
|
||||
@@ -1861,6 +1897,7 @@ function OnboardingWizardInner({
|
||||
settings = generalSettings;
|
||||
managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
|
||||
} catch {
|
||||
if (!isCurrent()) return null;
|
||||
setAdapterEnvError(
|
||||
"Could not load environment settings to determine which environment to test in. Retry the test.",
|
||||
);
|
||||
@@ -1883,6 +1920,7 @@ function OnboardingWizardInner({
|
||||
// default to the managed sandbox instead of sending the hidden local id.
|
||||
visibleEnvironmentIds: environmentList.map((environment) => environment.id),
|
||||
});
|
||||
if (!isCurrent()) return null;
|
||||
const result = await agentsApi.testEnvironment(
|
||||
createdCompanyId,
|
||||
adapterType,
|
||||
@@ -1892,16 +1930,18 @@ function OnboardingWizardInner({
|
||||
environmentId,
|
||||
}
|
||||
);
|
||||
if (!isCurrent()) return null;
|
||||
setAdapterEnvResult(result);
|
||||
adapterEnvResultAppliedStoredLoginRef.current = appliedStoredClaudeLoginBinding;
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return null;
|
||||
setAdapterEnvError(
|
||||
err instanceof Error ? err.message : "Adapter environment test failed"
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setAdapterEnvLoading(false);
|
||||
if (isCurrent()) setAdapterEnvLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1965,8 +2005,11 @@ function OnboardingWizardInner({
|
||||
setStep(5);
|
||||
return;
|
||||
}
|
||||
if (hiringAgentRef.current) return;
|
||||
hiringAgentRef.current = true;
|
||||
if (hiringAgentRef.current !== null) return;
|
||||
const attempt = ++connectAttemptRef.current;
|
||||
const isCurrent = () => connectAttemptRef.current === attempt && stillTheSameCompany(createdCompanyId);
|
||||
hiringAgentRef.current = attempt;
|
||||
autoConnectStartedRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -2023,10 +2066,11 @@ function OnboardingWizardInner({
|
||||
let apiKeyStored = false;
|
||||
if (credentialMode === "api" && !selectedApiKey && apiKey.trim()) {
|
||||
apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
|
||||
if (!apiKeyStored) return;
|
||||
if (!apiKeyStored || !isCurrent()) return;
|
||||
}
|
||||
if (credentialMode !== "api" && canUseLocalLogin && managedProvider && !managedBindingForStep() && !savedSubscription && !savedKeys.storedLogin.data) {
|
||||
await localLogin.connect();
|
||||
if (!isCurrent()) return;
|
||||
managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
|
||||
}
|
||||
const managedBinding = managedBindingForStep();
|
||||
@@ -2044,7 +2088,8 @@ function OnboardingWizardInner({
|
||||
if (!(err instanceof ApiError) || err.status !== 404) throw err;
|
||||
storedClaudeLogin = null;
|
||||
}
|
||||
if (stillTheSameCompany(createdCompanyId)) setClaudeOAuthStatus(storedClaudeLogin);
|
||||
if (!isCurrent()) return;
|
||||
setClaudeOAuthStatus(storedClaudeLogin);
|
||||
}
|
||||
const shouldApplyStoredClaudeLogin = storedClaudeLogin !== null;
|
||||
const hireAdapterConfig = shouldApplyStoredClaudeLogin
|
||||
@@ -2073,8 +2118,8 @@ function OnboardingWizardInner({
|
||||
: null;
|
||||
const result =
|
||||
cachedUsable ??
|
||||
(await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin));
|
||||
if (!result) return;
|
||||
(await runAdapterEnvironmentTest(hireAdapterConfig, shouldApplyStoredClaudeLogin, isCurrent));
|
||||
if (!result || !isCurrent()) return;
|
||||
// Block the hire on a failed environment test. Also block it on a
|
||||
// pass or a warn result that reports missing authentication — the
|
||||
// agent cannot run without one of those.
|
||||
@@ -2091,7 +2136,7 @@ function OnboardingWizardInner({
|
||||
// `agentRole` always holds a value now (see its default), so this is a
|
||||
// type narrowing rather than a gate — but it stays, because a future
|
||||
// path that clears the role must not reach a hire that silently no-ops.
|
||||
if (!agentRole) return;
|
||||
if (!agentRole || !isCurrent()) return;
|
||||
|
||||
const hireName = agentName.trim() || AGENT_ROLE_LABELS[agentRole];
|
||||
|
||||
@@ -2103,13 +2148,14 @@ function OnboardingWizardInner({
|
||||
// same name on the same source is that agent: adopt it and move on to
|
||||
// Review, the way a run that remembers its hire does.
|
||||
const existingAgents = await agentsApi.list(createdCompanyId).catch(() => null);
|
||||
if (!isCurrent()) return;
|
||||
const existing = existingAgents?.find(
|
||||
(agent) =>
|
||||
agent.name.trim().toLowerCase() === hireName.toLowerCase() &&
|
||||
agent.adapterType === adapterType,
|
||||
);
|
||||
if (existing) {
|
||||
if (!stillTheSameCompany(createdCompanyId)) return;
|
||||
if (!isCurrent()) return;
|
||||
setCreatedAgentId(existing.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.list(createdCompanyId)
|
||||
@@ -2118,6 +2164,9 @@ function OnboardingWizardInner({
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidation prevents sending a hire for an abandoned verification.
|
||||
// Once submitted, this existing API has no rollback contract; navigation
|
||||
// can only suppress its completion, just as it does for a manual hire.
|
||||
const hire = await agentsApi.hire(createdCompanyId, {
|
||||
// The name is optional; an agent that reaches here without one is
|
||||
// named for the job it was hired to do rather than left blank.
|
||||
@@ -2132,6 +2181,7 @@ function OnboardingWizardInner({
|
||||
onboardingFirstAgent: true,
|
||||
runtimeConfig: { ...buildNewAgentRuntimeConfig(), ...(managedBinding ? { aiConnection: managedBinding } : {}) }
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
if (hire.approval) {
|
||||
await approvalsApi.approve(
|
||||
hire.approval.id,
|
||||
@@ -2149,19 +2199,20 @@ function OnboardingWizardInner({
|
||||
// `onboardingFirstAgent` marker above (the chief-of-staff persona). The
|
||||
// wizard no longer composes or overwrites it.
|
||||
|
||||
if (!stillTheSameCompany(createdCompanyId)) return;
|
||||
if (!isCurrent()) return;
|
||||
setCreatedAgentId(agent.id);
|
||||
// Advance to the Review step — the lead is now online. The user drives
|
||||
// strategy + hiring from the planning chat after "Get started".
|
||||
setStep(5);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||
} finally {
|
||||
hiringAgentRef.current = false;
|
||||
setLoading(false);
|
||||
if (hiringAgentRef.current === attempt) hiringAgentRef.current = null;
|
||||
if (isCurrent()) setLoading(false);
|
||||
// Authentication is already saved. A failed probe or hire must offer a
|
||||
// retry with that account, rather than keep the completed login busy.
|
||||
if (connectCredentialStored && stillTheSameCompany(createdCompanyId)) {
|
||||
if (connectCredentialStored && isCurrent()) {
|
||||
connectingSinceRef.current = null;
|
||||
setConnectAuthUrl(null);
|
||||
setConnectPhase((phase) => phase === "connecting" ? "ready" : phase);
|
||||
@@ -2545,6 +2596,12 @@ function OnboardingWizardInner({
|
||||
placeholder="e.g. Chief of staff"
|
||||
value={agentName}
|
||||
onChange={(e) => setAgentName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing && agentName.trim()) {
|
||||
e.preventDefault();
|
||||
setStep(4);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -2581,6 +2638,7 @@ function OnboardingWizardInner({
|
||||
settling={connectPhase === "unwindRow"}
|
||||
onSelect={(id) => {
|
||||
if (connectPhase !== "idle") return;
|
||||
autoConnectStartedRef.current = false;
|
||||
setSourcePicked(true);
|
||||
setAdapterType(id);
|
||||
if (id === "opencode_local") setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
@@ -2589,20 +2647,6 @@ function OnboardingWizardInner({
|
||||
}}
|
||||
/>
|
||||
|
||||
{credentialMode === "subscription" && savedKeys.subscriptions.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<SavedProviderKeySelect
|
||||
options={savedKeys.subscriptions}
|
||||
value={savedSubscription?.id ?? ""}
|
||||
onChange={(id) => setSubscriptionId(createdCompanyId ? { companyId: createdCompanyId, id } : null)}
|
||||
loading={false}
|
||||
error={false}
|
||||
kind="subscription"
|
||||
disabled={loading || adapterEnvLoading || connectPhase === "connecting"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fades on the first beat but keeps its space until the
|
||||
second, so pressing a tile moves nothing vertically.
|
||||
Once a sign-in is running there is no switching to keys
|
||||
@@ -2669,7 +2713,12 @@ function OnboardingWizardInner({
|
||||
not, and they outlive the space by a beat. See
|
||||
`connectCardMounted`.
|
||||
*/}
|
||||
{!connectCardMounted ? null : credentialMode === "api" ? (
|
||||
{!connectCardMounted ? null : connectProgress ? (
|
||||
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{connectProgress}
|
||||
</p>
|
||||
) : credentialMode === "api" ? (
|
||||
<OnboardingLoginCard
|
||||
instruction={savedKeys.options.length ? "Choose a saved API key or enter a new one" : `Provide your ${
|
||||
CONNECT_SOURCE_NAMES[adapterType] ?? adapterType
|
||||
@@ -2789,11 +2838,9 @@ function OnboardingWizardInner({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : adapterType === "claude_local" && savedKeys.storedLogin.data ? (
|
||||
<p className="text-sm text-muted-foreground">Use your saved Claude subscription for this agent.</p>
|
||||
) : connectStepHasNoSandbox ? (
|
||||
) : hasSavedSubscription || localLogin.status === "ready" ? null : connectStepHasNoSandbox ? (
|
||||
canUseLocalLogin && managedProvider ? (
|
||||
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { setError(null); localLogin.retry(); } }} />
|
||||
<LocalProviderLoginInstructions adapterType={adapterType} login={{ ...localLogin, retry: () => { autoConnectStartedRef.current = false; setError(null); localLogin.retry(); } }} />
|
||||
) : <p className="text-xs text-muted-foreground">This environment does not support browser sign-in. Choose another sign-in environment or connect with an API key.</p>
|
||||
) : null}
|
||||
</motion.div>
|
||||
@@ -2805,13 +2852,7 @@ function OnboardingWizardInner({
|
||||
way to judge one — and the agent's model is changeable
|
||||
later, where its work gives the choice meaning. */}
|
||||
|
||||
{/* The environment check runs without being shown: Connect
|
||||
probes the adapter before hiring (see handleGiveHeartbeat)
|
||||
and blocks the hire on a fail. The idle card — probe
|
||||
explainer plus a "Test now" button — is gone from this
|
||||
step, so this block renders only when a probe has actually
|
||||
found something: the checks the blocking error tells the
|
||||
customer to fix have to be visible somewhere. */}
|
||||
{/* Progress is shown above; failed checks remain actionable here. */}
|
||||
{/* Not while the hire is in flight. The probe's result lands
|
||||
before the hire it gates has finished, so a warn that does
|
||||
not block — the identity and target INFO checks, which
|
||||
|
||||
@@ -151,10 +151,14 @@ function installStorybookApiFixtures() {
|
||||
// The cloud-tenant shape, and what the onboarding connect step resolves
|
||||
// its login environment through: without it the step looks for a local
|
||||
// default and never finds the managed sandbox.
|
||||
enableManagedSandboxOnly: true,
|
||||
enableManagedSandboxOnly: onboardingFixtureState.environments !== "local",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/health") {
|
||||
return Response.json({ deploymentMode: "authenticated", localAiLoginSupported: onboardingFixtureState.environments === "local" });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/instance/settings") {
|
||||
return Response.json({});
|
||||
}
|
||||
@@ -307,10 +311,31 @@ function installStorybookApiFixtures() {
|
||||
? Response.json({ secretId: "saved-claude-subscription", latestVersion: 1 })
|
||||
: new Response(null, { status: 404 });
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/ai-connections\/local\/attempts$/.test(url.pathname)) {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
return Response.json({ sessionId: "local-storybook", expiresAt: new Date(Date.now() + 1_800_000).toISOString(), command: body.provider === "anthropic"
|
||||
? "CLAUDE_CONFIG_DIR='/paperclip/login' claude auth login"
|
||||
: "CODEX_HOME='/paperclip/login' codex login --device-auth" });
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/ai-connections\/local\/check$/.test(url.pathname)) {
|
||||
return Response.json({ status: onboardingFixtureState.localLoginStatus });
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/ai-connections\/local$/.test(url.pathname)) {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
if (onboardingFixtureState.localLoginStatus !== "ready") return Response.json({ error: "Finish signing in, then try Connect again." }, { status: 422 });
|
||||
if (onboardingFixtureState.connectPending) await new Promise(() => {});
|
||||
onboardingFixtureState.savedManagedSubscription = body.provider;
|
||||
return Response.json({ connectionId: "managed-storybook", grantId: "grant-storybook" });
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/ai-connections$/.test(url.pathname)) {
|
||||
const provider = onboardingFixtureState.savedManagedSubscription;
|
||||
return init?.method === "POST"
|
||||
? Response.json({ connectionId: "managed-storybook", grantId: "grant-storybook" })
|
||||
: Response.json({ currentUserId: "user-storybook", connections: [] });
|
||||
: Response.json({ currentUserId: "user-storybook", connections: provider ? [{
|
||||
id: "managed-storybook", grantId: "grant-storybook", companyId: "company-storybook",
|
||||
provider, method: "subscription", name: provider === "anthropic" ? "My Claude subscription" : "My OpenAI subscription",
|
||||
ownership: "personal", ownerUserId: "user-storybook", status: "connected", isDefault: true,
|
||||
}] : [] });
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/me\/user-secrets$/.test(url.pathname)) {
|
||||
return Response.json(onboardingFixtureState.savedApiKeys ? ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].map((key) => ({
|
||||
@@ -333,12 +358,18 @@ function installStorybookApiFixtures() {
|
||||
/^\/api\/companies\/[^/]+\/adapters\/([^/]+)\/test-environment$/,
|
||||
);
|
||||
if (testEnvMatch) {
|
||||
if (onboardingFixtureState.testPending) await new Promise(() => {});
|
||||
await new Promise(resolve => setTimeout(resolve, onboardingFixtureState.testDelayMs));
|
||||
if (onboardingFixtureState.testFailuresRemaining > 0) {
|
||||
onboardingFixtureState.testFailuresRemaining--;
|
||||
return Response.json({ error: "The provider did not respond. Try connecting again." }, { status: 502 });
|
||||
}
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
const env = body.adapterConfig?.env ?? {};
|
||||
const usesSavedCodex = onboardingFixtureState.savedCodexLogin && env.CODEX_HOME?.secretId === "saved-codex-home";
|
||||
const usesSavedKey = onboardingFixtureState.savedApiKeys && ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"].some((key) => env[key]?.type === "user_secret_ref" && env[key]?.key === `${key}.setup.storybook`);
|
||||
const usesSavedClaude = onboardingFixtureState.savedClaudeLogin && env.CLAUDE_CODE_OAUTH_TOKEN?.type === "user_secret_ref" && env.CLAUDE_CODE_OAUTH_TOKEN?.key === "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
return Response.json(usesSavedCodex || usesSavedKey || usesSavedClaude ? { adapterType: testEnvMatch[1], status: "pass", checks: [], testedAt: new Date(0).toISOString() } : storybookEnvironmentTest(testEnvMatch[1]));
|
||||
return Response.json((body.aiConnection?.provider === onboardingFixtureState.savedManagedSubscription) || usesSavedCodex || usesSavedKey || usesSavedClaude ? { adapterType: testEnvMatch[1], status: "pass", checks: [], testedAt: new Date(0).toISOString() } : storybookEnvironmentTest(testEnvMatch[1]));
|
||||
}
|
||||
if (/^\/api\/companies\/[^/]+\/agent-hires$/.test(url.pathname)) {
|
||||
// `approval: null` on purpose. A hire that returns one sends the wizard
|
||||
@@ -524,7 +555,7 @@ function installStorybookApiFixtures() {
|
||||
if (secretsListMatch) {
|
||||
const [, companyId] = secretsListMatch;
|
||||
return Response.json(
|
||||
companyId === "company-storybook" ? [...storybookSecrets, ...(onboardingFixtureState.savedCodexLogin ? [{ ...storybookSecrets[0], id: "saved-codex-home", key: "codex_home_saved", name: "CODEX_HOME_team-account" }] : [])] : [],
|
||||
companyId === "company-storybook" && onboardingFixtureState.environments !== "local" ? [...storybookSecrets, ...(onboardingFixtureState.savedCodexLogin ? [{ ...storybookSecrets[0], id: "saved-codex-home", key: "codex_home_saved", name: "CODEX_HOME_team-account" }] : [])] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -749,7 +780,7 @@ const preview: Preview = {
|
||||
(Story, context) => {
|
||||
const theme = context.globals.theme === "light" ? "light" : "dark";
|
||||
return (
|
||||
<StorybookProviders key={theme} theme={theme}>
|
||||
<StorybookProviders key={`${context.id}:${theme}`} theme={theme}>
|
||||
<Story />
|
||||
</StorybookProviders>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AdapterAuthSignal } from "@paperclipai/shared";
|
||||
export type OnboardingEnvironmentState =
|
||||
/** A cloud tenant as it should be: one managed sandbox, sign-in reachable. */
|
||||
| "managed-sandbox"
|
||||
| "local"
|
||||
/** The broken shape seen on staging — the step can offer no place to test. */
|
||||
| "none";
|
||||
|
||||
@@ -32,6 +33,12 @@ interface FixtureState {
|
||||
savedApiKeys: boolean;
|
||||
savedClaudeLogin: boolean;
|
||||
savedCodexLogin: boolean;
|
||||
localLoginStatus: "sign_in_required" | "ready";
|
||||
savedManagedSubscription: "anthropic" | "openai" | null;
|
||||
connectPending: boolean;
|
||||
testDelayMs: number;
|
||||
testPending: boolean;
|
||||
testFailuresRemaining: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +51,12 @@ export const onboardingFixtureState: FixtureState = {
|
||||
savedApiKeys: false,
|
||||
savedClaudeLogin: false,
|
||||
savedCodexLogin: false,
|
||||
localLoginStatus: "sign_in_required",
|
||||
savedManagedSubscription: null,
|
||||
connectPending: false,
|
||||
testDelayMs: 0,
|
||||
testPending: false,
|
||||
testFailuresRemaining: 0,
|
||||
};
|
||||
|
||||
export function setOnboardingFixtureState(next: Partial<FixtureState>): void {
|
||||
@@ -56,6 +69,12 @@ export function resetOnboardingFixtureState(): void {
|
||||
onboardingFixtureState.savedApiKeys = false;
|
||||
onboardingFixtureState.savedClaudeLogin = false;
|
||||
onboardingFixtureState.savedCodexLogin = false;
|
||||
onboardingFixtureState.localLoginStatus = "sign_in_required";
|
||||
onboardingFixtureState.savedManagedSubscription = null;
|
||||
onboardingFixtureState.connectPending = false;
|
||||
onboardingFixtureState.testDelayMs = 0;
|
||||
onboardingFixtureState.testPending = false;
|
||||
onboardingFixtureState.testFailuresRemaining = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +85,10 @@ export function resetOnboardingFixtureState(): void {
|
||||
*/
|
||||
export function storybookEnvironments(): unknown[] {
|
||||
if (onboardingFixtureState.environments === "none") return [];
|
||||
if (onboardingFixtureState.environments === "local") return [{
|
||||
id: "environment-storybook-local", name: "This computer", driver: "local",
|
||||
status: "active", config: {}, metadata: { defaultForInstance: true },
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: STORYBOOK_SANDBOX_ENVIRONMENT_ID,
|
||||
|
||||
@@ -215,7 +215,14 @@ export const ConnectAModel: StoryObj = {
|
||||
return resetOnboardingFixtureState;
|
||||
},
|
||||
render: () => <WizardArc />,
|
||||
play: () => advance("Connect a model"),
|
||||
play: async () => {
|
||||
const name = await screen.findByRole("textbox", { name: "Agent name" }, { timeout: STEP_TIMEOUT_MS });
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, " {Enter}");
|
||||
await expect(screen.getByRole("button", { name: PRIMARY })).toBeDisabled();
|
||||
await userEvent.type(name, "Chief of staff{Enter}");
|
||||
await screen.findByRole("heading", { name: "Connect a model" }, { timeout: STEP_TIMEOUT_MS });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -411,7 +418,8 @@ export const ConnectWithSavedChatGptSubscription: StoryObj = {
|
||||
play: async () => {
|
||||
await advance("Connect a model");
|
||||
await userEvent.click(screen.getByRole("radio", {name: /OpenAI/}));
|
||||
await screen.findByRole("combobox", {name: "Saved subscription"}, {timeout: STEP_TIMEOUT_MS});
|
||||
await screen.findByRole("heading", { name: "Let's get started..." }, { timeout: STEP_TIMEOUT_MS });
|
||||
await expect(screen.queryByRole("combobox", { name: "Saved subscription" })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -424,6 +432,71 @@ export const ConnectWithSavedClaudeSubscription: StoryObj = {
|
||||
play: async () => {
|
||||
await advance("Connect a model");
|
||||
await pickFirstSource();
|
||||
await screen.findByRole("heading", { name: "Let's get started..." }, { timeout: STEP_TIMEOUT_MS });
|
||||
await expect(screen.queryByRole("combobox", { name: "Saved API key" })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Local terminal sign-in with the production wizard and controllable API responses. */
|
||||
function localSubscriptionStory(
|
||||
provider: "Claude" | "OpenAI",
|
||||
state: "sign-in" | "detected" | "testing" | "retry" | "success",
|
||||
): StoryObj {
|
||||
return {
|
||||
name: `Local ${provider} · ${state}`,
|
||||
beforeEach: () => {
|
||||
setOnboardingFixtureState({
|
||||
environments: "local",
|
||||
localLoginStatus: state === "detected" ? "ready" : "sign_in_required",
|
||||
connectPending: state === "detected",
|
||||
testPending: state === "testing",
|
||||
testFailuresRemaining: state === "retry" ? 1 : 0,
|
||||
testDelayMs: state === "success" || state === "retry" ? 800 : 0,
|
||||
});
|
||||
return resetOnboardingFixtureState;
|
||||
},
|
||||
render: () => <WizardArc />,
|
||||
play: async () => {
|
||||
await advance("Connect a model");
|
||||
await userEvent.click(screen.getByRole("radio", { name: new RegExp(provider) }));
|
||||
if (state === "detected") {
|
||||
await screen.findByRole("button", { name: "Connecting…" }, { timeout: STEP_TIMEOUT_MS });
|
||||
await expect(screen.getByRole("button", { name: "Connecting…" })).toBeDisabled();
|
||||
} else {
|
||||
await screen.findByText(/Run this in a terminal/, {}, { timeout: STEP_TIMEOUT_MS });
|
||||
if (state === "sign-in") {
|
||||
await expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
return;
|
||||
}
|
||||
// Simulate the terminal finishing; the user's return triggers the real
|
||||
// login hook, which must advance without a second Connect click.
|
||||
setOnboardingFixtureState({ localLoginStatus: "ready" });
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
if (state === "testing") {
|
||||
await screen.findByRole("button", { name: "Testing…" }, { timeout: STEP_TIMEOUT_MS });
|
||||
await expect(screen.getByRole("button", { name: "Testing…" })).toBeDisabled();
|
||||
await expect(screen.getByRole("status")).toHaveTextContent("Testing connection…");
|
||||
} else if (state === "retry") {
|
||||
await screen.findByText("The provider did not respond. Try connecting again.", {}, { timeout: STEP_TIMEOUT_MS });
|
||||
await expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
} else {
|
||||
await screen.findByRole("heading", { name: "Let's get started..." }, { timeout: STEP_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
await expect(screen.queryByRole("combobox", { name: "Saved subscription" })).not.toBeInTheDocument();
|
||||
await expect(screen.queryByText(/Run this in a terminal/)).not.toBeInTheDocument();
|
||||
await expect(screen.queryByRole("button", { name: "Start sign-in again" })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const LocalClaudeSignInRequired = localSubscriptionStory("Claude", "sign-in");
|
||||
export const LocalCodexSignInRequired = localSubscriptionStory("OpenAI", "sign-in");
|
||||
export const LocalClaudeDetected = localSubscriptionStory("Claude", "detected");
|
||||
export const LocalCodexDetected = localSubscriptionStory("OpenAI", "detected");
|
||||
export const LocalClaudeTesting = localSubscriptionStory("Claude", "testing");
|
||||
export const LocalCodexTesting = localSubscriptionStory("OpenAI", "testing");
|
||||
export const LocalClaudeRetry = localSubscriptionStory("Claude", "retry");
|
||||
export const LocalCodexRetry = localSubscriptionStory("OpenAI", "retry");
|
||||
export const LocalClaudeSuccess = localSubscriptionStory("Claude", "success");
|
||||
export const LocalCodexSuccess = localSubscriptionStory("OpenAI", "success");
|
||||
Reference in new issue
Block a user