From b5a607cc33d1a087db14d4bfee52f1449ede1e41 Mon Sep 17 00:00:00 2001
From: Roomote
Date: Thu, 27 Aug 2026 11:16:57 +0000
Subject: [PATCH] feat: add trial inference choice to setup
---
.../setup/SetupDocs.client.test.tsx | 1 +
.../(onboarding)/setup/SetupSignedInFlow.tsx | 9 ++
.../StepConfigureInference.client.test.tsx | 149 ++++++++++++++++++
.../setup/StepConfigureInference.tsx | 93 +++++++++++
.../StepInferenceProvider.client.test.tsx | 40 +----
.../setup/StepInferenceProvider.tsx | 54 -------
.../(onboarding)/setup/hooks.client.test.tsx | 85 ++++++++++
apps/web/src/app/(onboarding)/setup/hooks.ts | 19 +++
.../src/app/(onboarding)/setup/setup-docs.ts | 1 +
.../src/app/(onboarding)/setup/types.test.ts | 4 +
apps/web/src/app/(onboarding)/setup/types.ts | 4 +
11 files changed, 366 insertions(+), 93 deletions(-)
create mode 100644 apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx
create mode 100644 apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx
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) => (
+
+ {children}
+
+ ),
+ 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.
+
+
+
+
chooseTrialInference.mutate()}
+ >
+ {chooseTrialInference.isPending ? (
+
+ ) : (
+
+ )}
+
+ Use free Roomote trial inference
+
+
+
+
+
+
+ Configure your provider
+
+
+
+
+
+
+ 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.
-
-
- chooseTrialInference.mutate()}
- >
- {chooseTrialInference.isPending ? : null}
- Start with free credits
-
-
-
-
- Or connect your own provider:
-
- >
- ) : null}
-
> = {}) {
} as unknown as ReturnType);
}
+function trialModelSetup(overrides: Partial> = {}) {
+ return {
+ runtimeRoomoteModel: null,
+ runtimeRoomoteModelSatisfied: false,
+ runtimeProviderId: 'openrouter',
+ persistedRoomoteModel: null,
+ persistedProviderId: null,
+ preselectedProvider: 'openrouter',
+ setupSatisfied: true,
+ setupSatisfiedByRuntimeEnv: true,
+ chatgptConnected: false,
+ providers: [
+ {
+ id: 'openrouter',
+ label: 'OpenRouter',
+ runtimeApiKeySatisfied: true,
+ savedApiKeySatisfied: false,
+ trialKeySatisfied: true,
+ },
+ ],
+ ...overrides,
+ };
+}
+
function mockReadyForRepository({
onboardingTaskId = null,
selectedRepositoryIds = [],
@@ -292,6 +316,67 @@ describe('useSetupFlow', () => {
expect(result.current.step).toBe('env-vars');
});
+ it('shows the inference choice before provider configuration when trial inference is available', async () => {
+ mockStatus({ modelSetup: trialModelSetup() });
+
+ const { result } = renderHook(() => useSetupFlow());
+
+ await waitFor(() => {
+ expect(result.current.step).toBe('welcome');
+ });
+
+ act(() => {
+ result.current.goToNextStep();
+ });
+
+ expect(result.current.step).toBe('inference');
+ });
+
+ it('returns from custom provider configuration to the trial choice', async () => {
+ markSetupWelcomeSeen();
+ mockStatus({ modelSetup: trialModelSetup() });
+
+ const { result } = renderHook(() => useSetupFlow());
+
+ await waitFor(() => {
+ expect(result.current.step).toBe('inference');
+ });
+
+ act(() => {
+ result.current.goToStep('env-vars', { revisit: true });
+ });
+ expect(result.current.step).toBe('env-vars');
+
+ act(() => {
+ result.current.goToPreviousStep();
+ });
+ expect(result.current.step).toBe('inference');
+ });
+
+ it('skips custom provider configuration after trial inference is chosen', async () => {
+ markSetupWelcomeSeen();
+ mockStatus({
+ modelSetup: trialModelSetup(),
+ setupNewState: {
+ authProvider: null,
+ modelProvider: 'openrouter',
+ computeProvider: null,
+ sourceControlProvider: null,
+ selectedRepositoryIds: [],
+ onboardingTaskId: null,
+ onboardingTaskStartedAt: null,
+ slackChannel: null,
+ slackThreadTs: null,
+ },
+ });
+
+ const { result } = renderHook(() => useSetupFlow());
+
+ await waitFor(() => {
+ expect(result.current.step).toBe('source-control-provider');
+ });
+ });
+
it('skips the wizard welcome when the bootstrap flow already showed it', async () => {
// The signed-out bootstrap flow marks the welcome screen as seen when
// "Get started" is clicked; after account creation the signed-in wizard
diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts
index a3d0193cf..617a25ac8 100644
--- a/apps/web/src/app/(onboarding)/setup/hooks.ts
+++ b/apps/web/src/app/(onboarding)/setup/hooks.ts
@@ -47,6 +47,7 @@ const PINNABLE_SETUP_STEPS: readonly SetupStep[] = [
'auth-provider',
'auth-env-vars',
'slack',
+ 'inference',
'env-vars',
'source-control-provider',
'source-control-config',
@@ -393,6 +394,24 @@ export function useSetupFlow(
)?.setupSatisfied ??
false)
);
+ case 'inference': {
+ const trialInferenceAvailable = status.modelSetup.providers?.some(
+ (provider) => provider.trialKeySatisfied === true,
+ );
+ const operatorProviderConfigured = status.modelSetup.providers?.some(
+ (provider) =>
+ provider.savedApiKeySatisfied ||
+ (provider.runtimeApiKeySatisfied && !provider.trialKeySatisfied),
+ );
+
+ return (
+ !trialInferenceAvailable ||
+ operatorProviderConfigured ||
+ status.modelSetup.runtimeRoomoteModelSatisfied ||
+ status.modelSetup.persistedRoomoteModel !== null ||
+ status.setupNewState.modelProvider !== null
+ );
+ }
case 'env-vars':
return status.modelSetup.setupSatisfied;
case 'source-control-provider':
diff --git a/apps/web/src/app/(onboarding)/setup/setup-docs.ts b/apps/web/src/app/(onboarding)/setup/setup-docs.ts
index 12a657694..3b54101e5 100644
--- a/apps/web/src/app/(onboarding)/setup/setup-docs.ts
+++ b/apps/web/src/app/(onboarding)/setup/setup-docs.ts
@@ -18,6 +18,7 @@ const SETUP_DOC_PATHS: Record = {
'auth-provider': 'communications',
'auth-env-vars': 'communications',
slack: 'providers/communications/slack',
+ inference: 'models',
'env-vars': 'models',
'source-control-provider': 'source-control',
'source-control-config': 'source-control',
diff --git a/apps/web/src/app/(onboarding)/setup/types.test.ts b/apps/web/src/app/(onboarding)/setup/types.test.ts
index 7b88bbb05..d8f84c062 100644
--- a/apps/web/src/app/(onboarding)/setup/types.test.ts
+++ b/apps/web/src/app/(onboarding)/setup/types.test.ts
@@ -8,6 +8,7 @@ describe('getSetupSteps', () => {
expect(new Set(emailPasswordSteps)).toEqual(new Set(SETUP_STEPS));
expect(emailPasswordSteps).toEqual([
'welcome',
+ 'inference',
'env-vars',
'source-control-provider',
'source-control-config',
@@ -24,5 +25,8 @@ describe('getSetupSteps', () => {
it('uses the canonical order when communication handled authentication', () => {
expect(getSetupSteps(true)).toBe(SETUP_STEPS);
+ expect(SETUP_STEPS.indexOf('inference')).toBe(
+ SETUP_STEPS.indexOf('env-vars') - 1,
+ );
});
});
diff --git a/apps/web/src/app/(onboarding)/setup/types.ts b/apps/web/src/app/(onboarding)/setup/types.ts
index f44a32690..cb5858e39 100644
--- a/apps/web/src/app/(onboarding)/setup/types.ts
+++ b/apps/web/src/app/(onboarding)/setup/types.ts
@@ -22,6 +22,10 @@ const SETUP_STEP_DEFINITIONS = [
id: 'slack',
title: 'Connect Slack',
},
+ {
+ id: 'inference',
+ title: 'Configure inference',
+ },
{
id: 'env-vars',
title: 'Configure inference provider',