diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 1743d4e3f91..c82482c5f14 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -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()); + 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]); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 5daf58cd448..ee2005bda98 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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(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, - appliedStoredClaudeLoginBinding = false + appliedStoredClaudeLoginBinding = false, + isCurrent = () => true, ): Promise { 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 /> @@ -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 && ( -
- setSubscriptionId(createdCompanyId ? { companyId: createdCompanyId, id } : null)} - loading={false} - error={false} - kind="subscription" - disabled={loading || adapterEnvLoading || connectPhase === "connecting"} - /> -
- )} - {/* 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 ? ( +

+ + {connectProgress} +

+ ) : credentialMode === "api" ? ( - ) : adapterType === "claude_local" && savedKeys.storedLogin.data ? ( -

Use your saved Claude subscription for this agent.

- ) : connectStepHasNoSandbox ? ( + ) : hasSavedSubscription || localLogin.status === "ready" ? null : connectStepHasNoSandbox ? ( canUseLocalLogin && managedProvider ? ( - { setError(null); localLogin.retry(); } }} /> + { autoConnectStartedRef.current = false; setError(null); localLogin.retry(); } }} /> ) :

This environment does not support browser sign-in. Choose another sign-in environment or connect with an API key.

) : null} @@ -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 diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index ebabff60004..0f465155f20 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -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 ( - + ); diff --git a/ui/storybook/fixtures/onboardingEnvironment.ts b/ui/storybook/fixtures/onboardingEnvironment.ts index daea0747d4b..60d63053450 100644 --- a/ui/storybook/fixtures/onboardingEnvironment.ts +++ b/ui/storybook/fixtures/onboardingEnvironment.ts @@ -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): 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, diff --git a/ui/storybook/stories/onboarding-agent-arc.stories.tsx b/ui/storybook/stories/onboarding-agent-arc.stories.tsx index 8e05032562f..80e90f9a44c 100644 --- a/ui/storybook/stories/onboarding-agent-arc.stories.tsx +++ b/ui/storybook/stories/onboarding-agent-arc.stories.tsx @@ -215,7 +215,14 @@ export const ConnectAModel: StoryObj = { return resetOnboardingFixtureState; }, render: () => , - 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: () => , + 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");