diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx index dc9f5b568..f03711bda 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx @@ -18,6 +18,7 @@ describe('SetupDocs', () => { expect(getSetupDocsPath('slack', { authProvider: 'microsoft' })).toBe( 'providers/communications/microsoft-teams', ); + expect(getSetupDocsPath('inference')).toBe('models'); expect( getSetupDocsPath('source-control-connect', { sourceControlProvider: 'github', diff --git a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx index d9be3fbd8..219dd77b7 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx @@ -31,6 +31,7 @@ import { import { StepTelegramSetup } from './StepTelegramSetup'; import { StepDiscordSetup } from './StepDiscordSetup'; import { StepInferenceProvider } from './StepInferenceProvider'; +import { StepConfigureInference } from './StepConfigureInference'; import { StepComputeProvider } from './StepComputeProvider'; import { StepComputeConfig } from './StepComputeConfig'; import { StepSourceControlProvider } from './StepSourceControlProvider'; @@ -352,6 +353,14 @@ export function SetupSignedInFlow() { bootstrapMode={false} /> ))} + {step === 'inference' && ( + + goToStep('env-vars', { revisit: true }) + } + /> + )} {step === 'env-vars' && ( ({ + mutateMock: vi.fn(), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + setupNew: { + chooseTrialInference: { + mutationOptions: (options: Record) => options, + }, + status: { + queryKey: () => ['setupNew.status'], + }, + }, + }), +})); + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useMutation: vi.fn(), + useQueryClient: vi.fn(), + }; +}); + +vi.mock('lucide-react', () => ({ + Gift: (props: SVGProps) => ( + + ), + Plug: (props: SVGProps) => ( + + ), +})); + +vi.mock('@/components/system', () => ({ + ArrowRight: (props: SVGProps) => , + Button: ({ + children, + size: _size, + variant: _variant, + ...props + }: { + children: ReactNode; + size?: string; + variant?: string; + } & ButtonHTMLAttributes) => ( + + ), + Spinner: (props: SVGProps) => , +})); + +vi.mock('./StepTitle', () => ({ + StepTitle: ({ text }: { text: string }) =>

{text}

, +})); + +const mockUseMutation = vi.mocked(useMutation); +const mockUseQueryClient = vi.mocked(useQueryClient); + +import { StepConfigureInference } from './StepConfigureInference'; + +describe('StepConfigureInference', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseMutation.mockReturnValue({ + mutate: mutateMock, + isPending: false, + } as unknown as ReturnType); + mockUseQueryClient.mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as ReturnType); + }); + + it('renders the trial and custom choices with the requested copy', () => { + render( + , + ); + + expect( + screen.getByRole('heading', { name: 'Configure inference' }), + ).toBeInTheDocument(); + expect(screen.getByText(/Roomote needs a model provider/).textContent).toBe( + 'Roomote needs a model provider for, you know, AI stuff.If you want, we can give you a few credits to try Roomote out or you can configure your provider directly.', + ); + expect( + screen.getByRole('button', { + name: 'Use free Roomote trial inference', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Configure your provider' }), + ).toBeInTheDocument(); + expect(screen.getByTestId('gift')).toBeInTheDocument(); + expect(screen.getByTestId('plug')).toBeInTheDocument(); + expect( + screen.getByText('Roomote trial inference goes through OpenRouter.'), + ).toBeInTheDocument(); + }); + + it('starts trial inference and advances after the setup mutation succeeds', async () => { + const onUseTrial = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { + name: 'Use free Roomote trial inference', + }), + ); + + expect(mutateMock).toHaveBeenCalledOnce(); + + const options = mockUseMutation.mock.calls[0]?.[0] as + | { onSuccess?: () => Promise | void } + | undefined; + await options?.onSuccess?.(); + expect(onUseTrial).toHaveBeenCalledOnce(); + }); + + it('opens custom provider configuration without mutating trial state', () => { + const onConfigureProvider = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Configure your provider' }), + ); + + expect(onConfigureProvider).toHaveBeenCalledOnce(); + expect(mutateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx new file mode 100644 index 000000000..00c185c6f --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Gift, Plug } from 'lucide-react'; +import { toast } from 'sonner'; + +import { useTRPC } from '@/trpc/client'; +import { ArrowRight, Button, Spinner } from '@/components/system'; +import { cn } from '@/lib/utils'; + +import { StepTitle } from './StepTitle'; +import { getSetupStepDefinition } from './types'; + +const INFERENCE_STEP = getSetupStepDefinition('inference'); + +export function StepConfigureInference({ + onUseTrial, + onConfigureProvider, +}: { + onUseTrial: () => void; + onConfigureProvider: () => void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const chooseTrialInference = useMutation( + trpc.setupNew.chooseTrialInference.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.setupNew.status.queryKey(), + }); + onUseTrial(); + }, + onError: (error) => toast.error(error.message), + }), + ); + const choiceButtonClassName = cn( + 'group flex w-full items-center gap-3 py-5', + 'hover:text-accent-foreground hover:bg-foreground', + ); + + return ( +
+ +
+

+ Roomote needs a model provider for, you know, AI stuff. +
+ If you want, we can give you a few credits to try Roomote out or you + can configure your provider directly. +

+ +
+ + +
+ +

+ Roomote trial inference goes through OpenRouter. +

+
+
+ ); +} diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx index ebc6b4baa..6ff45f598 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.client.test.tsx @@ -27,9 +27,6 @@ vi.mock('@/trpc/client', () => ({ saveModelConfig: { mutationOptions: (options: Record) => options, }, - chooseTrialInference: { - mutationOptions: (options: Record) => options, - }, status: { queryKey: () => ['setupNew.status'], }, @@ -114,8 +111,6 @@ vi.mock('@/components/system', () => ({ {children} ), - Card: ({ children }: { children: ReactNode }) =>
{children}
, - CardContent: ({ children }: { children: ReactNode }) =>
{children}
, Check: (props: SVGProps) => , Lock: (props: SVGProps) => , Input: ({ @@ -790,7 +785,7 @@ describe('StepInferenceProvider ChatGPT subscription', () => { }); }); -describe('StepInferenceProvider free trial', () => { +describe('StepInferenceProvider trial fallback', () => { beforeEach(() => { vi.clearAllMocks(); mockUseQueryClient.mockReturnValue({ @@ -808,39 +803,6 @@ describe('StepInferenceProvider free trial', () => { }; } - it('offers free credits when a trial key is available and starts the trial on click', () => { - render( - , - ); - - fireEvent.click( - screen.getByRole('button', { name: 'Start with free credits' }), - ); - - expect(mutateMock).toHaveBeenCalled(); - }); - - it('does not offer free credits without a trial key', () => { - render( - , - ); - - expect( - screen.queryByRole('button', { name: 'Start with free credits' }), - ).not.toBeInTheDocument(); - }); - it('keeps the API key field editable for a trial-satisfied provider', () => { render( { - await queryClient.invalidateQueries({ - queryKey: trpc.setupNew.status.queryKey(), - }); - onContinue(); - }, - onError: (error) => { - toast.error(error.message); - }, - }), - ); const discoverProviderModels = useMutation( trpc.taskModels.discoverProviderModels.mutationOptions(), ); @@ -234,13 +219,6 @@ export function StepInferenceProvider({ selectedProviderStatus?.trialKeySatisfied !== true; const hasSavedProviderKey = selectedProviderStatus?.savedApiKeySatisfied === true; - const trialInferenceAvailable = useMemo( - () => - modelSetup.providers.some( - (provider) => provider.trialKeySatisfied === true, - ), - [modelSetup.providers], - ); const primaryCredentialLabel = selectedProviderStatus?.envVarLabel ?? 'API key'; const additionalEnvFields = selectedProviderStatus?.additionalEnvFields ?? []; @@ -283,7 +261,6 @@ export function StepInferenceProvider({ requiresConnectionName && connectionName.trim().length === 0; const isActionDisabled = saveModelConfig.isPending || - chooseTrialInference.isPending || discoverProviderModels.isPending || qualifyProviderModel.isPending || selectedProvider === null || @@ -384,37 +361,6 @@ export function StepInferenceProvider({

- {trialInferenceAvailable ? ( - <> - - -
-

- Start with free credits -

-

- Your first tasks are on us, running on an efficient model. You - can connect your own provider anytime from Settings. -

-
- -
-
-

- Or connect your own provider: -

- - ) : null} -